From 902c77992ac1d9a338627b40207b105d68fa390c Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 01:08:47 +0900 Subject: [PATCH] =?UTF-8?q?[WL-811def-fix]=20=EC=B9=B4=EB=A9=94=EB=9D=BC?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20FIX=203=20+=20Minor=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 reviews/WL-811def.md 의 Major 3 · Minor 5 반영. Assets/Script/** 0줄 · Core 0 · RealCamera.cs 0(공개 필드/메서드만). FIX-1 보스 킬캠 포기: 처치 스택 뒷부분(MobActor.Set_Die → MonsterSimpleInfo.DeadTarget → Off → Set_LockTarget(null) → RealCamera.Init_Cam)이 궤도 3필드를 덮어써 ZoomStillOurs() 가 항상 false 였다. Phase.Pending 을 추가해 킬캠 시작을 다음 프레임으로 미루고 그때 base 를 재취득한다(KillCamRebased 로 계측). 엘리트/일반은 Set_LockTarget 경로가 없어 회귀 0. FIX-2 arr_cCamData[1] 영구 덮어쓰기: 원값 스냅샷 → 보스 처치(OnKilled)·사라짐/비활성(TickBossPreset)·씬 전환·SO off· ReleaseAll 에 복원(BossPresetRestored/Abandoned). 프리셋 적용을 busy 스킵 검사 뒤로 옮겨 스킵된 스폰은 프리셋도 안 쓴다. FIX-3 Set_Target 누적: 보스 리타겟을 공개 필드 target 직접 대입으로 바꿔 list_tfCamactionTarget 길이 불변(기존 대상이 있을 때만 리타겟 = isTarget 유효). 완전 정합용 Set_Target 오버로드는 811n 요청 대상. Minor: MobHitFlash.OnKilled 에 WLReactionRunner.Ensure() · DamageAggregator 재진입 가드(Harvest/Dispatch 분리 + s_dispatching 백업·복원 · ReentrantHits) · SO 폴백 상수 제거(KillChain/DamageAggregator Tick) · BestChainEver 중복 갱신 1곳으로 · s_burstHeld↔EffectBudget 불변식 주석 + 홀드 만료 시 선반납 · 킬캠 쿨/런 카운터 소모 의도 주석. 프로브: WL811def_Probe.FixCheck 추가(FIX-1 Init_Cam 덮어쓰기 뒤 줌 유지 · FIX-2 프리셋 원복/busy 순서 · FIX-3 리스트 길이 불변). Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL811def_Probe.cs | 87 +++++++++- Assets/WL/Camera/CombatCameraDirector.cs | 154 +++++++++++++++--- Assets/WL/Combat/Reaction/DamageAggregator.cs | 45 ++++- Assets/WL/Combat/Reaction/KillChain.cs | 17 +- Assets/WL/Combat/Reaction/MobHitFlash.cs | 3 + 5 files changed, 270 insertions(+), 36 deletions(-) diff --git a/AgentScripts/WL811def_Probe.cs b/AgentScripts/WL811def_Probe.cs index 2aa4e412b..69904ac98 100644 --- a/AgentScripts/WL811def_Probe.cs +++ b/AgentScripts/WL811def_Probe.cs @@ -14,6 +14,7 @@ // 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.FixCheck // 리뷰 FIX-1/2/3 회귀 (Title Play) // 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 @@ -42,6 +43,7 @@ public static class WL811def_Probe 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 void FixCheck() { Launch(h => h.Co_FixCheck()); } public static object Dump() { @@ -122,10 +124,11 @@ public static class WL811def_Probe 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 + + " abandoned=" + CombatCameraDirector.KillCamAbandoned + " rebased=" + CombatCameraDirector.KillCamRebased + " pending=" + CombatCameraDirector.KillCamPending + + " boss=" + CombatCameraDirector.BossCamCount + " bossSkip=" + CombatCameraDirector.BossCamSkipped + " preset(applied/restored/abandoned/active)=" + CombatCameraDirector.BossPresetApplied + "/" + CombatCameraDirector.BossPresetRestored + "/" + CombatCameraDirector.BossPresetAbandoned + "/" + CombatCameraDirector.BossPresetActive + " 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("merge: hits=" + DamageAggregator.HitCount + " flush=" + DamageAggregator.FlushCount + " maxMerged=" + DamageAggregator.MaxHitsMerged + " evict=" + DamageAggregator.EvictCount + " active=" + DamageAggregator.ActiveSlots + " reentrant=" + DamageAggregator.ReentrantHits + " dispatching=" + DamageAggregator.Dispatching); 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(); @@ -201,6 +204,13 @@ public static class WL811def_Probe return pc; } + /// 진단용 읽기 — RealCamera.list_tfCamactionTarget(private · 인트로 CamStatus 0 에서만 소비)의 길이 확인(FIX-3). + static List CamActionList(RealCamera cam) + { + var f = typeof(RealCamera).GetField("list_tfCamactionTarget", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + return f != null && cam != null ? f.GetValue(cam) as List : null; + } + static Material LoadSampleMat() { return UnityEditor.AssetDatabase.LoadAssetAtPath(SampleMat); // 에디터 전용 프로브(Roslyn run_script 에 UNITY_EDITOR 심볼이 없을 수 있어 #if 미사용) @@ -568,6 +578,79 @@ public static class WL811def_Probe Save("toggle", _sb); Cleanup(); } + /// 리뷰 WL-811def FIX 회귀: ① 처치 스택의 Init_Cam 덮어쓰기 뒤에도 킬캠 줌이 유지되나(FIX-1) + /// ② arr_cCamData[1] 프리셋이 busy 검사 뒤에만 써지고 보스 처치·사라짐에 원복되나(FIX-2) ③ list_tfCamactionTarget 길이가 불변인가(FIX-3). + public IEnumerator Co_FixCheck() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + var st = WLCombatCameraSettings.Instance; + _sb.AppendLine("# WL811def FixCheck " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCameraSettings.Enabled + + " zoom=" + st.killCamZoomScale + " scaleUp=" + st.killCamScaleDistanceUp + " preset=" + st.bossPresetApply + "(" + st.bossPresetDistance + " m · " + st.bossPresetCameraHeight + "°) retarget=" + st.bossCamRetarget + " bossCam=" + st.bossCamSeconds + " s"); + CombatCameraDirector.ResetDiagnostics(); CombatCameraDirector.ResetRun(); + InstallCamera(); + yield return null; yield return null; + var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), null); _trash.Add(boss.gameObject); + var boss2 = MakeMob("__wl811def_boss2", eSubRol.Boss, Front(5f) + Vector3.right * 2f, null); _trash.Add(boss2.gameObject); + var list = CamActionList(_cam); + int list0 = list != null ? list.Count : -1; + var d1 = _cam.arr_cCamData[1]; + float p0Min = d1.minDistance, p0Max = d1.maxDistance, p0H = d1.cameraHeight, p0Up = d1.DistanceUp; + yield return null; + _sb.AppendLine(Pre() + "before " + CamRow() + " | [1]=" + p0Min.ToString("F2") + "/" + p0Max.ToString("F2") + "/" + p0H.ToString("F0") + "° up=" + p0Up.ToString("F2") + " · camActionList=" + list0); + + // ───── ① FIX-1: 실전투 보스 처치 스택 재현 — RaiseKilled 직후 같은 프레임에 Set_LockTarget(null) → Init_Cam() 이 궤도 3필드를 덮어쓴다 + _cam.Set_LockTarget(boss.transform); // 원본 MonsterSimpleInfo.OnClick_Lock 경로(보스만 락타겟) → Init_Cam → arr_cCamData[1] + yield return null; + _sb.AppendLine(Pre() + "① 보스 락타겟 → " + CamRow() + " (Init_Cam 이 [1] 값을 씀)"); + float lockedMin = _cam.minDistance; + CombatEvents.RaiseKilled(boss); + _cam.Set_LockTarget(null); // ← 같은 스택 · MonsterSimpleInfo.DeadTarget → Off() 가 하는 일 → Init_Cam → [0] 값 + _sb.AppendLine(Pre() + " Killed + 같은 프레임 Set_LockTarget(null) → pending=" + CombatCameraDirector.KillCamPending + " (기대 True · 시작을 미룸) · 궤도 " + lockedMin.ToString("F2") + "→" + _cam.minDistance.ToString("F2") + " (외부 덮어쓰기) killCam=" + CombatCameraDirector.KillCamCount); + float expectZoom = _cam.arr_cCamData[0].minDistance * st.killCamZoomScale; + float zoomMin = 99f; float zoomReach = -1f; float tK = Time.unscaledTime; int rows = 0; + while (Time.unscaledTime - tK < st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.4f) + { + yield return null; + zoomMin = Mathf.Min(zoomMin, _cam.minDistance); + if (zoomReach < 0f && Mathf.Approximately(_cam.minDistance, expectZoom)) zoomReach = (Time.unscaledTime - tK) * 1000f; + if ((rows++ % 8) == 0) _sb.AppendLine(Pre() + " " + CamRow()); + if (zoomReach >= 0f && !CombatCameraDirector.KillCamActive) break; + } + _sb.AppendLine(Pre() + " 결과: 줌 최소=" + zoomMin.ToString("F3") + " (기대 " + expectZoom.ToString("F3") + " = [0] " + _cam.arr_cCamData[0].minDistance.ToString("F2") + " × " + st.killCamZoomScale + ") 도달 " + zoomReach.ToString("F0") + " ms · rebased=" + CombatCameraDirector.KillCamRebased + + " (기대 1 = base 재취득) · abandoned=" + CombatCameraDirector.KillCamAbandoned + " (기대 0 · 수정 전엔 1) · measured=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3") + " s · 복원 " + CamRow()); + + // ───── ② FIX-2: 프리셋 스냅샷·복원 · busy 검사 뒤 적용 + CombatCameraDirector.ResetRun(); + CombatEvents.RaiseSpawned(boss); + _sb.AppendLine(Pre() + "② Boss Spawned → bossCam=" + CombatCameraDirector.BossCamCount + " presetApplied=" + CombatCameraDirector.BossPresetApplied + " active=" + CombatCameraDirector.BossPresetActive + + " [1]=" + d1.minDistance.ToString("F2") + "/" + d1.maxDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "° up=" + d1.DistanceUp.ToString("F2") + " (기대 " + st.bossPresetDistance + "/" + st.bossPresetDistance + "/" + st.bossPresetCameraHeight + "°)"); + int applied0 = CombatCameraDirector.BossPresetApplied, skip0 = CombatCameraDirector.BossCamSkipped; + yield return null; + CombatEvents.RaiseSpawned(boss2); // 오빗 진행 중 = busy → 스킵 · 프리셋도 추가 적용 0(수정 전엔 프리셋만 다시 써졌다) + _sb.AppendLine(Pre() + " 진행 중 두 번째 Boss Spawned → bossSkip+" + (CombatCameraDirector.BossCamSkipped - skip0) + " (기대 1) presetApplied+" + (CombatCameraDirector.BossPresetApplied - applied0) + " (기대 0 = busy 검사 뒤로 이동) last=" + CombatCameraDirector.LastInfo); + yield return Wait(st.bossCamSeconds + 0.3f); + _sb.AppendLine(Pre() + " 오빗 종료 → bossActive=" + CombatCameraDirector.BossCamActive + " presetActive=" + CombatCameraDirector.BossPresetActive + " (기대 False/True = 프리셋은 보스전 동안 유지) [1]=" + d1.minDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "°" + + " · ③ camActionList " + list0 + "→" + (list != null ? list.Count : -1) + " (기대 동일 = Set_Target 누적 0) · target 복원=" + ReferenceEquals(_cam.target, _dummy) + " · " + CamRow()); + CombatCameraDirector.ResetCooldown(); + CombatEvents.RaiseKilled(boss); // 보스 처치 → 프리셋 즉시 원복(쿨 스킵과 무관하게 복원 경로가 먼저다) + _sb.AppendLine(Pre() + " 보스 처치 → restored=" + CombatCameraDirector.BossPresetRestored + " (기대 1) abandoned=" + CombatCameraDirector.BossPresetAbandoned + " (기대 0) active=" + CombatCameraDirector.BossPresetActive + + " [1]=" + d1.minDistance.ToString("F2") + "/" + d1.maxDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "° up=" + d1.DistanceUp.ToString("F2") + " (기대 원값 " + p0Min.ToString("F2") + "/" + p0Max.ToString("F2") + "/" + p0H.ToString("F0") + "°/" + p0Up.ToString("F2") + ")"); + yield return Wait(st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.3f); + + // ②-2 사라짐(풀 반납 = SetActive(false)) 경로도 원복하는가 + CombatCameraDirector.ResetRun(); + CombatEvents.RaiseSpawned(boss2); + _sb.AppendLine(Pre() + " 재적용(boss2) → active=" + CombatCameraDirector.BossPresetActive + " [1]=" + d1.minDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "°"); + boss2.gameObject.SetActive(false); // Actor.Off() 와 같은 상태(IsDead() = !activeInHierarchy) + yield return null; yield return null; + _sb.AppendLine(Pre() + " 보스 사라짐(비활성) → restored=" + CombatCameraDirector.BossPresetRestored + " (기대 2) active=" + CombatCameraDirector.BossPresetActive + " (기대 False) [1]=" + d1.minDistance.ToString("F2") + "/" + d1.maxDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "° up=" + d1.DistanceUp.ToString("F2")); + yield return Wait(st.bossCamSeconds + 0.2f); + _sb.AppendLine(Pre() + "# 종료 " + CamRow() + " | [1]=" + d1.minDistance.ToString("F2") + "/" + d1.maxDistance.ToString("F2") + "/" + d1.cameraHeight.ToString("F0") + "° up=" + d1.DistanceUp.ToString("F2") + + " · camActionList=" + (list != null ? list.Count : -1) + " (기대 " + list0 + ") · abandoned=" + CombatCameraDirector.KillCamAbandoned + " rebased=" + CombatCameraDirector.KillCamRebased + " presetApplied/restored/abandoned=" + CombatCameraDirector.BossPresetApplied + "/" + CombatCameraDirector.BossPresetRestored + "/" + CombatCameraDirector.BossPresetAbandoned + + " · merge reentrant=" + DamageAggregator.ReentrantHits + " dispatching=" + DamageAggregator.Dispatching + " · reactionRunner=" + WLReactionRunner.Exists); + Save("fixcheck", _sb); Cleanup(); + } + /// 병합 후 실전투(메인 에디터 · Lead QA): 3 SO ON n초 / OFF n초 UnityStats 비교(WL785_Budget 스타일). public IEnumerator Co_Budget(float sec) { diff --git a/Assets/WL/Camera/CombatCameraDirector.cs b/Assets/WL/Camera/CombatCameraDirector.cs index 7ce454072..e74cfaac5 100644 --- a/Assets/WL/Camera/CombatCameraDirector.cs +++ b/Assets/WL/Camera/CombatCameraDirector.cs @@ -7,7 +7,8 @@ // 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 오빗 · 거리 배율 · 끝나면 전부 복원 +// Spawned(isBoss) → arr_cCamData[1] 보스 프리셋 덮어쓰기(옵션 · 원값 스냅샷 후 · 보스가 죽거나 사라지면 원복) · 추적 대상을 보스로 +// (공개 필드 target 직접 대입 = list_tfCamactionTarget 무접촉) · Add_Rot 로 sin 오빗 · 거리 배율 · 끝나면 전부 복원 // ComboStage(isLast) → CameraMoveLead.LeadMultiplier = comboLeadMultiplier (comboLeadSeconds 뒤 1 로) // // ■ 왜 RealCamera 공개 필드를 매 프레임 쓰는가 (사후 가산 기각 근거 = CameraMoveLead.cs / WLHitFeelCameraShake.cs 주석) @@ -21,6 +22,12 @@ // · 보스 캠 중 킬캠 줌은 스킵(슬로모만) · 킬캠 중 보스 스폰은 킬캠을 즉시 복원하고 보스 캠 시작 // · 씬 전환 시 상태·런 카운터 초기화 · 러너 파괴/앱 종료 시 전부 복원 // · 시간은 전부 unscaled(슬로모 중에도 램프가 늘어지지 않는다) · 핸들러 GC 0(verboseLog 때만 문자열) +// +// ■ 리뷰 WL-811def FIX 3건 반영 (2026-09-09 · #813) +// FIX-1 킬캠 시작을 **다음 프레임**으로 미루고 그때 base 궤도 값을 재취득 — 처치 스택 뒷부분(MonsterSimpleInfo.Off → Set_LockTarget(null) +// → RealCamera.Init_Cam)이 min/max/DistanceUp 을 덮어써 보스 킬캠 줌이 항상 포기되던 문제(Phase.Pending · KillCamRebased 로 계측) +// FIX-2 arr_cCamData[1](보스/락타겟 프리셋)을 덮어쓸 때 **원값 스냅샷** → 보스 처치·사라짐·씬 전환·SO off 에 복원 · 적용을 busy 스킵 검사 뒤로 +// FIX-3 보스 리타겟을 Set_Target(→ list_tfCamactionTarget 누적) 대신 **공개 필드 target 직접 대입**으로 — 리스트 길이 불변 // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; @@ -31,17 +38,19 @@ namespace WL.CombatCamera { public static class CombatCameraDirector { - enum Phase { None = 0, ZoomIn = 1, Hold = 2, ZoomOut = 3 } + enum Phase { None = 0, Pending = 1, ZoomIn = 2, Hold = 3, ZoomOut = 4 } // ── 킬캠 상태 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_baseMin, s_baseMax, s_baseUp; // 줌 시작 시점 스냅샷(FIX-1 = 처치 다음 프레임에 재취득) + static float s_preMin, s_preMax, s_preUp; // 처치 이벤트 시점 값 — 외부 덮어쓰기 계측용(KillCamRebased) static float s_wMin, s_wMax, s_wUp; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지) static bool s_scaleUp; static float s_lastKillCam = -999f; static int s_runCount; + static int s_pendingFrame; // FIX-1: 킬캠을 예약한 프레임(이 프레임에는 시작하지 않는다) static RealCamera s_cam; // ── 보스 캠 상태 @@ -52,6 +61,13 @@ namespace WL.CombatCamera static float s_bossYawApplied; static float s_bossBaseMin, s_bossBaseMax, s_bossWMin, s_bossWMax; + // ── 보스 프리셋(arr_cCamData[1]) 상태 — FIX-2: 원값 스냅샷 → 보스 처치·사라짐·씬 전환·SO off 에 복원 + static bool s_presetApplied; + static cCamData s_presetData; // 우리가 값을 쓴 프리셋(참조 · 원본 [Serializable] 클래스라 fake-null 아님) + static Actor s_presetBoss; // 프리셋을 건 보스(처치·풀 반납 감지) + static float s_presetOMin, s_presetOMax, s_presetOHeight, s_presetOUp; // 원값 + static float s_presetWMin, s_presetWMax, s_presetWHeight, s_presetWUp; // 우리가 쓴 값 + // ── 3타 리드 상태 static float s_leadUntil = -1f; @@ -60,7 +76,8 @@ namespace WL.CombatCamera // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int KillCamCount, KillCamSkippedRole, KillCamSkippedCooldown, KillCamSkippedRunCap, KillCamSkippedGuard, KillCamSkippedNoCam, KillCamSkippedBusy, KillCamAbandoned; - public static int BossCamCount, BossCamSkipped, BossPresetApplied, ComboLeadCount; + public static int KillCamRebased; // FIX-1: 미룬 사이에 외부(Init_Cam 등)가 궤도 값을 바꿔 base 를 다시 잡은 횟수 + public static int BossCamCount, BossCamSkipped, BossPresetApplied, BossPresetRestored, BossPresetAbandoned, ComboLeadCount; public static ArbiterResult LastSlowMoResult; public static float LastVictimViewportY = -1f; public static float LastKillCamMeasured; // 줌 시작 → 완전 복원까지(초 · unscaled) @@ -68,7 +85,9 @@ namespace WL.CombatCamera public static string LastInfo = ""; public static bool KillCamActive { get { return s_phase != Phase.None; } } + public static bool KillCamPending { get { return s_phase == Phase.Pending; } } public static bool BossCamActive { get { return s_bossActive; } } + public static bool BossPresetActive { get { return s_presetApplied; } } public static float CurrentZoomScale { get { return s_scale; } } public static int RunCount { get { return s_runCount; } } public static float LeadUntil { get { return s_leadUntil; } } @@ -114,6 +133,11 @@ namespace WL.CombatCamera { if (!WLCombatCameraSettings.Enabled) return; var st = St; + + // FIX-2: 프리셋을 건 보스가 죽었으면 즉시 원복(틱 폴백보다 한 프레임 빠르다). + // Set_Die 는 이 뒤에 Set_LockTarget(null) → Init_Cam() 으로 인덱스 0 을 읽으므로 여기서 되돌려도 이번 프레임 연출에 영향 0. + if (s_presetApplied && ReferenceEquals(e.victim, s_presetBoss)) RestoreBossPreset(); + if (!st.killCamEnabled) return; bool roleOk = e.subRole == eSubRol.Boss ? st.killCamOnBoss : e.subRole == eSubRol.Elite ? st.killCamOnElite : st.killCamOnNormal; @@ -125,6 +149,8 @@ namespace WL.CombatCamera 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; } + // 쿨·런 카운터는 여기서 소모한다 — 아래 스킵(카메라 없음·인트로·하단 가드)으로 슬로모만 나가도 "킬캠 1회를 썼다"로 친다. + // 의도: 연출 밀도(런당 ≤6 · 최소 간격 8 s)는 슬로모까지 포함한 체감 기준이다(리뷰 Minor-6 확인 사항). s_lastKillCam = now; s_runCount++; KillCamCount++; @@ -155,14 +181,39 @@ namespace WL.CombatCamera } if (s_phase != Phase.None) EndZoom(true); // (쿨 안이라 사실상 없음) 안전 + + // ── FIX-1(리뷰 Major-1): 여기서 바로 스냅샷·줌을 걸면 같은 호출 스택 뒷부분이 그 값을 덮어쓴다. + // MobActor.Set_Die 는 RaiseKilled(:435) 다음에 MonsterSimpleInfo.DeadTarget(:444) → Off() → 보스면 Set_LockTarget(null) + // → RealCamera.Init_Cam() 으로 minDistance/maxDistance/cameraHeight 와 Set_DistanceUp(false) 로 DistanceUp 을 프리셋 값으로 다시 쓴다. + // → 다음 틱의 ZoomStillOurs() 가 false → KillCamAbandoned · 보스 킬캠은 슬로모만 남았다. + // 그래서 **시작을 다음 프레임으로 미루고 그때 base 를 재취득**한다(엘리트/일반은 Set_LockTarget 경로가 없어 값이 같다 = 회귀 0 · 지연 1프레임). s_cam = cam; - s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp; + s_preMin = cam.minDistance; s_preMax = cam.maxDistance; s_preUp = cam.DistanceUp; s_scaleUp = st.killCamScaleDistanceUp; - s_phase = Phase.ZoomIn; s_phaseStart = s_killStart = now; - WriteZoom(1f); + s_scale = 1f; + s_phase = Phase.Pending; s_pendingFrame = Time.frameCount; s_phaseStart = s_killStart = now; 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")); + if (Verbose) Log("킬캠 예약(다음 프레임 base 재취득) victim=" + (e.victim != null ? e.victim.name : "?") + " sub=" + e.subRole + " 슬로모=" + LastSlowMoResult + " pre=" + s_preMin.ToString("F2") + "/" + s_preMax.ToString("F2") + "/" + s_preUp.ToString("F2")); + } + + /// FIX-1: 예약해 둔 킬캠을 다음 프레임에 시작한다 — 처치 프레임의 나머지 로직(Init_Cam 등)이 다 쓴 뒤의 값을 base 로 잡는다. + static void BeginZoom(float now) + { + if (Time.frameCount <= s_pendingFrame) return; // 처치와 같은 프레임에는 시작하지 않는다 + var cam = s_cam; + if (cam.IsEnter() || s_bossActive) // 미룬 사이에 인트로/보스 캠이 시작됐으면 슬로모만 + { + KillCamSkippedBusy++; LastInfo = "slowmo-only(busy)"; + s_phase = Phase.None; s_cam = null; LastKillCamMeasured = -1f; + if (Verbose) Log("킬캠 줌 취소(예약 중 인트로/보스 캠)"); + return; + } + s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp; + if (!Mathf.Approximately(s_baseMin, s_preMin) || !Mathf.Approximately(s_baseMax, s_preMax) || !Mathf.Approximately(s_baseUp, s_preUp)) KillCamRebased++; + s_phase = Phase.ZoomIn; s_phaseStart = now; + WriteZoom(1f); + if (Verbose) Log("킬캠 시작 base=" + s_baseMin.ToString("F2") + "/" + s_baseMax.ToString("F2") + "/" + s_baseUp.ToString("F2") + " rebased=" + KillCamRebased); } static void WriteZoom(float k) @@ -186,6 +237,12 @@ namespace WL.CombatCamera static void EndZoom(bool restore) { if (s_phase == Phase.None) return; + if (s_phase == Phase.Pending) // 아직 아무것도 쓰지 않았다 — 복원할 것도, 포기로 셀 것도 없다 + { + s_phase = Phase.None; s_scale = 1f; s_cam = null; + if (Verbose) Log("킬캠 예약 취소"); + return; + } if (restore && ZoomStillOurs()) { var cam = s_cam; @@ -203,6 +260,7 @@ namespace WL.CombatCamera if (s_phase == Phase.None) return; var st = St; if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndZoom(true); return; } + if (s_phase == Phase.Pending) { BeginZoom(now); return; } // FIX-1: 예약 → 다음 프레임 시작(base 재취득) if (!ZoomStillOurs()) { KillCamAbandoned++; s_phase = Phase.None; s_scale = 1f; s_cam = null; LastKillCamMeasured = -1f; if (Verbose) Log("킬캠 포기(외부가 거리 필드를 바꿈)"); return; } float t = now - s_phaseStart; @@ -232,25 +290,27 @@ namespace WL.CombatCamera 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++; - } - + // FIX-2: 프리셋 적용은 busy 스킵 검사 **뒤**로 — 보스 캠이 스킵되는데 프리셋만 써지는 일이 없게 한다. if (cam.IsEnter() || s_bossActive) { BossCamSkipped++; LastInfo = "bosscam(busy)"; return; } if (s_phase != Phase.None) EndZoom(true); + ApplyBossPreset(cam, st, e.actor); + float now = Time.unscaledTime; s_cam = cam; s_bossActive = true; s_bossStart = now; s_bossTf = e.actor.transform; + + // ── FIX-3(리뷰 Major-3): RealCamera.Set_Target 은 호출마다 list_tfCamactionTarget 에 항목을 쌓고(원본 :77-86), + // 그 리스트는 인트로(CamStatus 0) 분기에서만 소비된다(:197·212) → 보스 캠 1회당 2건이 영구히 남아 다음 인트로/컷 경로가 + // 우리 잔재를 첫 목표로 삼고 Start_Game() 이 지연된다. 그래서 **공개 필드 target 을 직접 대입**해 리스트를 건드리지 않는다. + // · isTarget(private)은 target 이 이미 있을 때만 true 이므로 기존 대상이 없으면 아예 리타겟하지 않는다(카메라 정지 방지). + // · m_Target(private Actor)은 이전 대상(PC)인 채로 남는다 = CameraMoveLead 의 "기준 속도" 출처만 PC 값 + // (리드 자체는 target 의 이동 차분으로 계산 · speedReference > 0 이면 애초에 안 쓴다) · 오빗이 끝나면 다시 정합. + // · 완전 정합이 필요하면 811n 에 Add_CamActionTarget 없는 Set_Target 오버로드를 요청한다(완료보고 기재). s_prevTarget = cam.target; s_bossRetargeted = false; - if (st.bossCamRetarget) { cam.Set_Target(s_bossTf); s_bossRetargeted = true; } + if (st.bossCamRetarget && s_prevTarget != null && s_bossTf != null) { cam.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; @@ -272,13 +332,62 @@ namespace WL.CombatCamera { cam.minDistance = s_bossBaseMin; cam.maxDistance = s_bossBaseMax; } - if (s_bossRetargeted && ReferenceEquals(cam.target, s_bossTf)) cam.Set_Target(s_prevTarget); // 외부가 대상을 바꿨으면 손대지 않음 + // FIX-3: 복원도 공개 필드 대입(리스트 무접촉) · 외부가 대상을 바꿨으면 손대지 않음 + if (s_bossRetargeted && s_prevTarget != null && ReferenceEquals(cam.target, s_bossTf)) cam.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")); } + // ───────────────────────────────────────── 보스 프리셋(arr_cCamData[1]) — FIX-2 + /// 보스/락타겟 프리셋을 덮어쓰기 전에 원값을 스냅샷한다. 이미 걸려 있으면 스냅샷을 덮어쓰지 않는다(중첩 보스). + static void ApplyBossPreset(RealCamera cam, WLCombatCameraSettings st, Actor boss) + { + if (!st.bossPresetApply || s_presetApplied) return; + var arr = cam.arr_cCamData; + if (arr == null || arr.Length <= 1 || arr[1] == null) return; + var d = arr[1]; + s_presetData = d; s_presetBoss = boss; + s_presetOMin = d.minDistance; s_presetOMax = d.maxDistance; s_presetOHeight = d.cameraHeight; s_presetOUp = d.DistanceUp; + d.minDistance = s_presetWMin = st.bossPresetDistance; + d.maxDistance = s_presetWMax = st.bossPresetDistance; + d.cameraHeight = s_presetWHeight = st.bossPresetCameraHeight; + if (st.bossPresetDistanceUp >= 0f) d.DistanceUp = st.bossPresetDistanceUp; + s_presetWUp = d.DistanceUp; + s_presetApplied = true; + BossPresetApplied++; + if (Verbose) Log("보스 프리셋 적용 [1] " + s_presetOMin.ToString("F2") + "/" + s_presetOMax.ToString("F2") + "/" + s_presetOHeight.ToString("F0") + "° → " + s_presetWMin.ToString("F2") + "/" + s_presetWMax.ToString("F2") + "/" + s_presetWHeight.ToString("F0") + "°"); + } + + /// 프리셋을 원값으로 되돌린다(보스 처치·사라짐·씬 전환·SO off·러너 파괴). 파일 규약대로 "우리가 쓴 값이 그대로 남아 있을 때만" 되돌린다. + static void RestoreBossPreset() + { + if (!s_presetApplied) return; + var d = s_presetData; + s_presetApplied = false; s_presetData = null; s_presetBoss = null; + if (d == null) return; + if (!Mathf.Approximately(d.minDistance, s_presetWMin) || !Mathf.Approximately(d.maxDistance, s_presetWMax) + || !Mathf.Approximately(d.cameraHeight, s_presetWHeight) || !Mathf.Approximately(d.DistanceUp, s_presetWUp)) + { + BossPresetAbandoned++; + if (Verbose) Log("보스 프리셋 복원 포기(외부가 값을 바꿈)"); + return; + } + d.minDistance = s_presetOMin; d.maxDistance = s_presetOMax; d.cameraHeight = s_presetOHeight; d.DistanceUp = s_presetOUp; + BossPresetRestored++; + if (Verbose) Log("보스 프리셋 복원 [1] " + s_presetOMin.ToString("F2") + "/" + s_presetOMax.ToString("F2") + "/" + s_presetOHeight.ToString("F0") + "°"); + } + + /// 프리셋 수명 감시 — 보스가 죽거나(IsDead) 풀로 돌아가거나(비활성) 파괴되면, SO 가 꺼지면 원값으로 돌린다. + static void TickBossPreset() + { + if (!s_presetApplied) return; + if (!WLCombatCameraSettings.Enabled) { RestoreBossPreset(); return; } // C8 — 끄면 즉시 원본 값 + var b = s_presetBoss; + if (b == null || b.IsDead()) RestoreBossPreset(); // IsDead() = DeadStatus || !activeInHierarchy(풀 반납) + } + static void TickBossCam(float now) { if (!s_bossActive) return; @@ -316,6 +425,7 @@ namespace WL.CombatCamera float now = Time.unscaledTime; TickKillCam(now); TickBossCam(now); + TickBossPreset(); TickLead(now); } @@ -324,6 +434,7 @@ namespace WL.CombatCamera { EndZoom(true); EndBossCam(true); + RestoreBossPreset(); CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; } @@ -332,6 +443,7 @@ namespace WL.CombatCamera // 카메라 오브젝트가 사라졌을 수 있다 — 복원 없이 상태만 버린다(복원은 ReleaseAll 이 러너 OnDisable 에서 시도) s_phase = Phase.None; s_scale = 1f; s_cam = null; s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; + RestoreBossPreset(); // FIX-2: 프리셋은 순수 데이터(cCamData)라 카메라가 파괴돼도 되돌리는 쪽이 안전하다(살아 있으면 원값 복귀) CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; s_runCount = 0; s_lastKillCam = -999f; } @@ -345,8 +457,8 @@ namespace WL.CombatCamera /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { - KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = 0; - BossCamCount = BossCamSkipped = BossPresetApplied = ComboLeadCount = 0; + KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = KillCamRebased = 0; + BossCamCount = BossCamSkipped = BossPresetApplied = BossPresetRestored = BossPresetAbandoned = ComboLeadCount = 0; LastKillCamMeasured = LastBossCamMeasured = 0f; LastVictimViewportY = -1f; LastInfo = ""; } } diff --git a/Assets/WL/Combat/Reaction/DamageAggregator.cs b/Assets/WL/Combat/Reaction/DamageAggregator.cs index d31f2676c..d708b79ce 100644 --- a/Assets/WL/Combat/Reaction/DamageAggregator.cs +++ b/Assets/WL/Combat/Reaction/DamageAggregator.cs @@ -34,12 +34,14 @@ namespace WL.Combat.Reaction static Slot[] s_slots; static int s_used; + static bool s_dispatching; // 리뷰 Minor-5(811b FIX-5 계열) 재진입 가드 — Flushed 구독자가 새 피해를 일으켜도 슬롯이 깨지지 않게 // ── 진단 public static bool Subscribed; - public static int HitCount, FlushCount, MaxHitsMerged, EvictCount; + public static int HitCount, FlushCount, MaxHitsMerged, EvictCount, ReentrantHits; public static string LastInfo = ""; public static int ActiveSlots { get { return s_used; } } + public static bool Dispatching { get { return s_dispatching; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() @@ -60,6 +62,7 @@ namespace WL.Combat.Reaction static void EnsureSlots(WLReactionSettings st) { + if (s_slots != null && s_dispatching) return; // Dispatch 중에는 배열을 갈아끼우지 않는다 — 바깥 순회가 보던 배열이 그대로 유지된다 int n = Mathf.Max(4, st.damageMergeSlots); if (s_slots == null || s_slots.Length != n) { s_slots = new Slot[n]; s_used = 0; } } @@ -75,6 +78,7 @@ namespace WL.Combat.Reaction WLReactionRunner.Ensure(); float now = Time.unscaledTime; HitCount++; + if (s_dispatching) ReentrantHits++; // 구독자가 Flushed 안에서 새 피해를 냈다(연쇄 피해 등) — 아래 슬롯 조작은 재진입 안전해야 한다 int idx = -1, free = -1, oldest = -1; float oldestStart = float.MaxValue; for (int i = 0; i < s_slots.Length; i++) @@ -83,9 +87,12 @@ namespace WL.Combat.Reaction if (ReferenceEquals(s_slots[i].victim, e.victim)) { idx = i; break; } if (s_slots[i].start < oldestStart) { oldestStart = s_slots[i].start; oldest = i; } } + // 리뷰 Minor-5: 슬롯을 잡기 **전에** Dispatch 하면 구독자가 그 빈 슬롯을 먼저 가져가 s_used 가 어긋난다 + // → 밀어내기는 Harvest(슬롯 비우기)만 먼저 하고, Dispatch 는 슬롯 조작이 다 끝난 뒤에 한다. + DamageMergeEvent evicted = default(DamageMergeEvent); bool hasEvicted = false; if (idx < 0) { - if (free < 0) { Flush(oldest, now); EvictCount++; free = oldest; } + if (free < 0) { hasEvicted = Harvest(oldest, now, out evicted); if (hasEvicted) 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; @@ -99,13 +106,22 @@ namespace WL.Combat.Reaction s_slots[idx].byMainPC |= e.byMainPC; s_slots[idx].pos = e.victim.Get_position(); + // 슬롯 조작 완료 — 여기서부터 Dispatch(재진입해도 안전) + if (hasEvicted) Dispatch(in evicted); 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 + DamageMergeEvent ev; + if (Harvest(i, now, out ev)) Dispatch(in ev); + } + + /// 슬롯을 비우고 결과 이벤트를 만든다(Dispatch 없음 = 재진입 안전). + static bool Harvest(int i, float now, out DamageMergeEvent ev) + { + if (i < 0 || s_slots == null || i >= s_slots.Length || !s_slots[i].used) { ev = default(DamageMergeEvent); return false; } + 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, @@ -113,8 +129,16 @@ namespace WL.Combat.Reaction }; if (ev.hits > MaxHitsMerged) MaxHitsMerged = ev.hits; s_slots[i].used = false; s_slots[i].victim = null; s_used--; + return true; + } + + /// 재진입 가드(811b FIX-5 계열 = 이전 값을 백업하고 복원)를 걸고 구독자에게 알린다. + static void Dispatch(in DamageMergeEvent ev) + { FlushCount++; - Flushed.Dispatch(in ev); + bool prev = s_dispatching; s_dispatching = true; + try { Flushed.Dispatch(in ev); } + finally { s_dispatching = prev; } if (Verbose) Log("플러시 victim=" + (ev.victim != null ? ev.victim.name : "?") + " total=" + ev.total.ToString("F0") + " hits=" + ev.hits + " cri=" + ev.anyCritical + " kill=" + ev.isKill); } @@ -122,9 +146,14 @@ namespace WL.Combat.Reaction { if (s_used <= 0 || s_slots == null) return; var st = St; - float win = st != null ? st.damageMergeWindowSeconds : 0.25f; + if (st == null) return; // 리뷰 Minor-7: 폴백 상수(값 이중 출처) 대신 그냥 반환 — 에셋이 없으면 Enabled 가 false 라 슬롯도 없다 + float win = st.damageMergeWindowSeconds; + // Dispatch 안에서 새 피해가 들어와도 EnsureSlots 가 배열 재할당을 막으므로 s_slots 는 순회 내내 같은 배열이다. for (int i = 0; i < s_slots.Length; i++) - if (s_slots[i].used && now - s_slots[i].start >= win) Flush(i, now); + { + DamageMergeEvent ev; + if (s_slots[i].used && now - s_slots[i].start >= win && Harvest(i, now, out ev)) Dispatch(in ev); + } } /// 데이터 API — 대상의 합산 중 값(813g 가 미리 읽고 싶을 때). 슬롯이 없으면 false. @@ -148,7 +177,7 @@ namespace WL.Combat.Reaction /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { - HitCount = FlushCount = MaxHitsMerged = EvictCount = 0; LastInfo = ""; + HitCount = FlushCount = MaxHitsMerged = EvictCount = ReentrantHits = 0; LastInfo = ""; Flushed.Raised = Flushed.Dispatched = 0; } } diff --git a/Assets/WL/Combat/Reaction/KillChain.cs b/Assets/WL/Combat/Reaction/KillChain.cs index f8545d3ce..a7992a6e5 100644 --- a/Assets/WL/Combat/Reaction/KillChain.cs +++ b/Assets/WL/Combat/Reaction/KillChain.cs @@ -89,6 +89,7 @@ namespace WL.Combat.Reaction s_chainKills++; s_lastKill = now; KillCount++; + if (s_chainKills > BestChainEver) BestChainEver = s_chainKills; // 리뷰 Minor-7: 티어 루프 안팎 중복 갱신을 여기 1곳으로 // 위치 링 + 무게중심(연쇄 안 최근 N개) s_pos[s_head] = e.position; @@ -105,7 +106,6 @@ namespace WL.Combat.Reaction { 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++; @@ -114,14 +114,20 @@ namespace WL.Combat.Reaction if (Verbose) Log("티어 " + tiers[i].name + " kills=" + s_chainKills + " centroid=" + s_centroid.ToString("F1") + " burst=" + burst); break; } - if (s_chainKills > BestChainEver) BestChainEver = s_chainKills; } + // ■ 불변식(리뷰 Minor-8ⓐ): s_burstHeld == true ⟺ EffectBudget.ClusterBurst 슬롯 1개를 우리가 잡고 있다. + // 획득은 여기(TryAcquire 성공 직후 s_burstHeld = true)뿐이고 반납은 ReleaseBurst 뿐이다. 홀드가 만료됐는데 Tick 이 아직 + // 돌지 않은 사이에 다음 처치가 오면 **먼저 반납하고** 다시 잡는다 — 그러지 않으면 상한을 2 이상으로 올렸을 때 획득 2 : 반납 1 로 샌다. 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 (s_burstHeld) + { + if (now < s_burstReleaseAt) { BurstDeniedBudget++; return false; } // 우리 슬롯이 아직 살아 있음(동시 ≤ 1) + ReleaseBurst(); // 홀드 만료 · Tick 보다 먼저 도착 → 반납 후 재획득 + } if (!EffectBudget.TryAcquire(EffectBudgetKind.ClusterBurst)) { BurstDeniedBudget++; return false; } s_burstHeld = true; s_burstReleaseAt = now + Mathf.Max(0.05f, st.clusterBurstHoldSeconds); @@ -154,8 +160,9 @@ namespace WL.Combat.Reaction if (s_chainKills > 0) { var st = St; - float win = st != null ? st.killChainWindowSeconds : 3f; - if (now - s_lastKill >= win) EndChain(now); + // 리뷰 Minor-7: 폴백 상수(SO 기본값의 사본 = 값 이중 출처)를 두지 않는다 — 에셋이 없으면 Enabled 가 false 라 연쇄가 시작되지도 않는다. + if (st == null) EndChain(now); + else if (now - s_lastKill >= st.killChainWindowSeconds) EndChain(now); } if (s_burstHeld && now >= s_burstReleaseAt) ReleaseBurst(); } diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs b/Assets/WL/Combat/Reaction/MobHitFlash.cs index 911ed359d..3a4d19e0c 100644 --- a/Assets/WL/Combat/Reaction/MobHitFlash.cs +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -119,6 +119,7 @@ namespace WL.Combat.Reaction if (!s_dissolveHasHdr && !s_dissolveHasRange) { SkippedNoProperty++; return; } var s = GetState(victim, st); if (s == null) return; + WLReactionRunner.Ensure(); // 리뷰 Minor-4: 첫 이벤트가 Killed 여도 씬 전환 훅이 붙어 s_states(파괴 액터 참조)가 씬을 넘지 않게 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; @@ -136,6 +137,8 @@ namespace WL.Combat.Reaction if (!s.blinkOn && !s.tint) return; s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f; var st = St; + // 리뷰 Minor-8ⓒ: st == null(에셋 자체가 없음)이면 Apply 를 못 부르지만, 그 상태에서는 Enabled 가 false 라 애초에 MPB 를 쓴 적이 없다 + // (블록이 있다면 RestoreAll 의 st == null 경로가 SetPropertyBlock(null) 로 뗀다) → 잔류 0. ⓑ s_states 는 씬 전환(ClearAll)까지 유지 = 풀 크기 상한. if (st != null) Apply(s, st); Deactivate(s); ClearCount++;