diff --git a/AgentScripts/WL811gh_Probe.cs b/AgentScripts/WL811gh_Probe.cs new file mode 100644 index 000000000..a9c1fb7ca --- /dev/null +++ b/AgentScripts/WL811gh_Probe.cs @@ -0,0 +1,581 @@ +// PD 지시 #813 · 발주서 WL-811gh — 임팩트 티어 · PC 피격 림라이트/넉백 · 대시 잔상 검증 프로브 +// (에디트 모드 전용 · Play 0 · 로그인 0 · 에셋 무수정) +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) +// unity command run_script --file AgentScripts/WL811gh_Probe.cs --entry WL811gh_Probe.Edit --timeout 600 +// +// 무엇을 재는가 +// A. 티어 4단 판정 — 가짜 HitConfirmed 4묶음(평타 · 크리 · 킬 · 보스킬) → 티어 인덱스 + 티어별 값 표 +// (히트스톱 ms · 셰이크 배수 · 펀치 배수 · 점멸 색/시간 배수 · 초당 상한) +// B. PC 피격 — 가짜 Damaged(PC) → MPB 플래시 on → 만료 후 off(잔류 0) + 넉백 벡터(방향·거리·NavMesh 가드) +// C. 대시 잔상 — 가짜 Dash(began) → 고스트 3개 스폰 · 알파 페이드 · 풀 반환 · EffectBudget 반환 +// D. GC — 각 경로 100회 반복의 GC.GetAllocatedBytesForCurrentThread() 증가분(0 B 목표) +// E. C8 — WLImpactTierSettings.RuntimeDisabled = true(= 에셋 enabled 0 과 같은 Enabled 게이트) +// → 티어 off(기존 WLHitFeel 2단 값) · 림라이트 0 · 고스트 0 +// F. 소스 확인 — WLHitFeel.cs 가 티어를 읽는지 · MobHitFlash.cs 가 점멸 배수를 쓰는지 · 원본 diff 0 +// +// 에디트 모드 주의 +// · [RuntimeInitializeOnLoadMethod] 는 Play 에서만 돈다 → 구독이 없다. 그래서 Probe* 헬퍼로 핸들러를 직접 부른다. +// · 프로브가 만드는 오브젝트는 전부 HideAndDontSave + DestroyImmediate. NavMeshData 는 Add 후 Remove. +// · 게임 수치는 전부 SO 에서 읽는다(C45). 이 파일은 어떤 에셋도 쓰지 않는다. +public static class WL811gh_Probe +{ + const string kOut = "AgentScripts/staging/WL811gh/EDIT_PROBE.txt"; + const string kHitFeel = "Assets/WL/Feel/WLHitFeel.cs"; + const string kBlink = "Assets/WL/Combat/Reaction/MobHitFlash.cs"; + + static System.Collections.Generic.List s_log = new System.Collections.Generic.List(); + static bool s_hooked; + + static void OnLog(string cond, string stack, UnityEngine.LogType t) + { + if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert) + s_log.Add(t + " | " + cond); + } + static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } } + static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } } + + // ── 프로브가 만든 임시 오브젝트 + static UnityEngine.GameObject s_pcGo, s_mobGo, s_attackerGo, s_modelGo; + static PCActor s_pc; + static MobActor s_mob, s_bossMob, s_attacker; + static UnityEngine.AI.NavMeshDataInstance s_navInstance; + static bool s_navAdded; + static string s_navPath = "(none)"; + + public static string Edit() + { + var sb = new System.Text.StringBuilder(); + sb.Append("# WL-811gh 프로브 (에디트 모드 · Play 0 · 로그인 0)\n"); + sb.Append("unity=").Append(UnityEngine.Application.unityVersion) + .Append(" isPlaying=").Append(UnityEngine.Application.isPlaying) + .Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n\n"); + + Hook(); s_log.Clear(); + try + { + Section0(sb); + BuildFakes(sb); + SectionA(sb); + SectionB(sb); + SectionC(sb); + SectionD(sb); + SectionE(sb); + } + catch (System.Exception ex) { sb.Append("\n!! EXCEPTION ").Append(ex.ToString()).Append("\n"); } + finally { Cleanup(); } + Unhook(); + + SectionF(sb); + + sb.Append("\n## 프로브 실행 중 Error/Exception 로그: ").Append(s_log.Count).Append("\n"); + for (int i = 0; i < s_log.Count && i < 20; i++) sb.Append(" ").Append(s_log[i]).Append("\n"); + + var dir = System.IO.Path.GetDirectoryName(kOut); + if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); + System.IO.File.WriteAllText(kOut, sb.ToString(), new System.Text.UTF8Encoding(false)); + return sb.ToString(); + } + + // ───────────────────────────────────────────────────────────────────────── + // 0. SO 로드 + // ───────────────────────────────────────────────────────────────────────── + static void Section0(System.Text.StringBuilder sb) + { + sb.Append("## 0. 설정 SO\n"); + var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; + sb.Append("WLImpactTierSettings = ").Append(st != null ? "found" : "NULL") + .Append(" · Enabled=").Append(WL.Combat.Reaction.WLImpactTierSettings.Enabled) + .Append(" · ImpactTier.Active=").Append(WL.Combat.Reaction.ImpactTier.Active).Append("\n"); + var feel = WL.Feel.WLHitFeel.Settings; + sb.Append("WLHitFeelSettings = ").Append(feel != null ? "found" : "NULL"); + if (feel != null) sb.Append(" · 기존 2단 hitStop=").Append(feel.hitStopDuration).Append("/").Append(feel.hitStopKillDuration) + .Append(" shakeKillMul=").Append(feel.shakeKillMultiplier).Append(" shakeAmp=").Append(feel.shakeAmplitude); + sb.Append("\n"); + var core = WL.Combat.Core.WLCombatCoreSettings.Instance; + sb.Append("WLCombatCoreSettings = ").Append(core != null ? "found" : "NULL"); + if (core != null) sb.Append(" · afterImageMax=").Append(core.afterImageMax) + .Append(" hitStopPerSecondMax=").Append(core.hitStopPerSecondMax) + .Append(" layerMaxSeconds=").Append(core.layerMaxSeconds); + sb.Append("\n"); + var blink = WL.Combat.Reaction.WLHitBlinkSettings.Instance; + sb.Append("WLHitBlinkSettings = ").Append(blink != null ? "found" : "NULL"); + if (blink != null) sb.Append(" · blinkOnSeconds=").Append(blink.blinkOnSeconds).Append(" blinkColor=").Append(blink.blinkColor); + sb.Append("\n\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 가짜 액터 · NavMesh + // ───────────────────────────────────────────────────────────────────────── + static void BuildFakes(System.Text.StringBuilder sb) + { + sb.Append("## 준비: 가짜 액터 · NavMesh\n"); + + // 비활성 GameObject 에 붙이면 Awake 가 돌지 않아 서버 데이터·테이블 의존이 생기지 않는다. + s_mobGo = new UnityEngine.GameObject("[811gh] mob"); + s_mobGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + s_mobGo.SetActive(false); + s_mob = s_mobGo.AddComponent(); + s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; + + var bossGo = new UnityEngine.GameObject("[811gh] boss"); + bossGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + bossGo.SetActive(false); + s_bossMob = bossGo.AddComponent(); + s_bossMob.m_Role = eRole.Mob; s_bossMob.m_SubRole = eSubRol.Boss; + s_bossMob.transform.SetParent(s_mobGo.transform, false); // Cleanup 한 번에 + + s_attackerGo = new UnityEngine.GameObject("[811gh] attacker(boss)"); + s_attackerGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + s_attackerGo.SetActive(false); + s_attacker = s_attackerGo.AddComponent(); + s_attacker.m_Role = eRole.Mob; s_attacker.m_SubRole = eSubRol.Boss; + + // PC — SkinnedMeshRenderer 가 있어야 잔상을 구울 수 있다 → 프로젝트의 실제 캐릭터 프리팹을 붙여 실측한다. + s_pcGo = new UnityEngine.GameObject("[811gh] pc"); + s_pcGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + s_pcGo.SetActive(false); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; + + string modelPath = FindSkinnedPrefab(); + if (!string.IsNullOrEmpty(modelPath)) + { + var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(modelPath); + if (prefab != null) + { + s_modelGo = UnityEngine.Object.Instantiate(prefab, s_pcGo.transform); + s_modelGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + } + } + var smrs = s_pcGo.GetComponentsInChildren(true); + var rends = s_pcGo.GetComponentsInChildren(true); + sb.Append("PC 모델 = ").Append(string.IsNullOrEmpty(modelPath) ? "(못 찾음)" : modelPath) + .Append(" · SkinnedMeshRenderer=").Append(smrs.Length).Append(" · Renderer=").Append(rends.Length).Append("\n"); + if (rends.Length > 0 && rends[0].sharedMaterial != null && rends[0].sharedMaterial.shader != null) + sb.Append("PC 셰이더[0] = ").Append(rends[0].sharedMaterial.shader.name).Append("\n"); + + // NavMesh — 넉백의 NavMesh 가드를 실제로 측정하기 위해 프로젝트의 NavMeshData 를 임시로 올린다. + AddNavMesh(sb); + sb.Append("\n"); + } + + static string FindSkinnedPrefab() + { + string[] roots = new string[] { "Assets/Res_Addr/PC", "Assets/Res_Addr", "Assets/ResWork" }; + for (int r = 0; r < roots.Length; r++) + { + if (!UnityEditor.AssetDatabase.IsValidFolder(roots[r])) continue; + var guids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new string[] { roots[r] }); + for (int i = 0; i < guids.Length && i < 400; i++) + { + var p = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); + var go = UnityEditor.AssetDatabase.LoadAssetAtPath(p); + if (go == null) continue; + var smr = go.GetComponentInChildren(true); + if (smr != null && smr.sharedMesh != null) return p; + } + } + return null; + } + + static void AddNavMesh(System.Text.StringBuilder sb) + { + var guids = UnityEditor.AssetDatabase.FindAssets("t:NavMeshData", new string[] { "Assets/WL", "Assets" }); + for (int i = 0; i < guids.Length && i < 40; i++) + { + var p = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); + var data = UnityEditor.AssetDatabase.LoadAssetAtPath(p); + if (data == null) continue; + s_navInstance = UnityEngine.AI.NavMesh.AddNavMeshData(data); + if (s_navInstance.valid) { s_navAdded = true; s_navPath = p; break; } + } + UnityEngine.AI.NavMeshHit hit; + bool any = UnityEngine.AI.NavMesh.SamplePosition(UnityEngine.Vector3.zero, out hit, 2000f, UnityEngine.AI.NavMesh.AllAreas); + sb.Append("NavMeshData = ").Append(s_navPath).Append(" · added=").Append(s_navAdded) + .Append(" · SamplePosition(0,0,0 r=2000)=").Append(any); + if (any) + { + sb.Append(" → ").Append(hit.position.ToString("F2")); + s_pcGo.transform.position = hit.position; + s_attackerGo.transform.position = hit.position + new UnityEngine.Vector3(1f, 0f, 0f); // PC 의 +X 쪽에 서 있다 → 넉백은 -X + } + sb.Append("\n"); + } + + static void Cleanup() + { + WL.Combat.Reaction.PcHitReaction.RestoreAll(); + WL.Combat.Reaction.DashAfterImage.RestoreAll(); + WL.Combat.Reaction.DashAfterImage.DestroyPool(); + WL.Combat.Reaction.PcHitReaction.ClearAll(); + WL.Combat.Reaction.DashAfterImage.ClearAll(); + WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = false; + if (s_navAdded) { UnityEngine.AI.NavMesh.RemoveNavMeshData(s_navInstance); s_navAdded = false; } + DestroyGo(ref s_pcGo); DestroyGo(ref s_mobGo); DestroyGo(ref s_attackerGo); + s_modelGo = null; s_pc = null; s_mob = null; s_bossMob = null; s_attacker = null; + } + + static void DestroyGo(ref UnityEngine.GameObject go) + { + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = null; + } + + // ───────────────────────────────────────────────────────────────────────── + // A. 티어 4단 + 티어별 값 + // ───────────────────────────────────────────────────────────────────────── + static void SectionA(System.Text.StringBuilder sb) + { + sb.Append("## A. 임팩트 티어 4단 (가짜 HitConfirmed 4묶음)\n"); + WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); + + var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; + if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } + + // 평균 기준선을 만든다(평타만 적재된다 · damageAverageSamples 만큼). + for (int i = 0; i < st.damageAverageSamples; i++) + { + WL.Combat.Reaction.ImpactTier.ClearStamp(); + WL.Combat.Reaction.ImpactTier.Resolve(s_mob, MakeDinfo(100d, false, false, false), false); + } + sb.Append("평균 기준선 = ").Append(WL.Combat.Reaction.ImpactTier.Average.ToString("F1")) + .Append(" (표본 ").Append(st.damageAverageSamples).Append("회 × 100 dmg)\n"); + + var t0 = Tier(s_mob, 100d, false, false, false); + var t1 = Tier(s_mob, 100d, true, false, false); + var t1b = Tier(s_mob, 100d, false, true, false); + var t1c = Tier(s_mob, 400d, false, false, false); + var t2 = Tier(s_mob, 100d, false, false, true); + var t3 = Tier(s_bossMob, 100d, false, false, true); + + sb.Append("판정: 평타=").Append(t0).Append(" · 크리=").Append(t1).Append(" · 스킬=").Append(t1b) + .Append(" · 평균×4(400dmg)=").Append(t1c).Append(" · 킬=").Append(t2).Append(" · 보스킬=").Append(t3).Append("\n"); + sb.Append("기대: Light · Heavy · Heavy · Heavy · Kill · BossKill → ") + .Append(t0 == WL.Combat.Reaction.ImpactTierKind.Light && + t1 == WL.Combat.Reaction.ImpactTierKind.Heavy && + t1b == WL.Combat.Reaction.ImpactTierKind.Heavy && + t1c == WL.Combat.Reaction.ImpactTierKind.Heavy && + t2 == WL.Combat.Reaction.ImpactTierKind.Kill && + t3 == WL.Combat.Reaction.ImpactTierKind.BossKill ? "PASS" : "FAIL").Append("\n\n"); + + sb.Append("| 티어 | 히트스톱 ms | 셰이크 배수 | 셰이크 진폭(m) | 펀치 배수 | 실펀치 | 점멸 색 배수 | 점멸 시간(s) | 초당 상한 |\n"); + sb.Append("|---|---|---|---|---|---|---|---|---|\n"); + var feel = WL.Feel.WLHitFeel.Settings; + var blink = WL.Combat.Reaction.WLHitBlinkSettings.Instance; + for (int i = 0; i < 4; i++) + { + var k = (WL.Combat.Reaction.ImpactTierKind)i; + float hs = WL.Combat.Reaction.ImpactTier.HitStopSeconds(k); + float sm = WL.Combat.Reaction.ImpactTier.ShakeMultiplier(k); + float pm = WL.Combat.Reaction.ImpactTier.PunchMultiplier(k); + float bc = WL.Combat.Reaction.ImpactTier.BlinkColorMultiplier(k); + float bt = WL.Combat.Reaction.ImpactTier.BlinkTimeMultiplier(k); + float amp = feel != null ? feel.shakeAmplitude * sm : 0f; + float realPunch = feel != null ? 1f + (UnityEngine.Mathf.Max(1f, feel.scalePunch) - 1f) * pm : 0f; + float bTime = blink != null ? blink.blinkOnSeconds * bt : 0f; + sb.Append("| ").Append(k).Append(" | ").Append((hs * 1000f).ToString("F0")) + .Append(" | ").Append(sm.ToString("F2")).Append(" | ").Append(amp.ToString("F3")) + .Append(" | ").Append(pm.ToString("F2")).Append(" | ").Append(realPunch.ToString("F3")) + .Append(" | ").Append(bc.ToString("F2")).Append(" | ").Append(bTime.ToString("F3")) + .Append(" | ").Append(st.tierMaxPerSecond != null && i < st.tierMaxPerSecond.Length ? st.tierMaxPerSecond[i].ToString("F0") : "-") + .Append(" |\n"); + } + sb.Append("티어 카운트 Light/Heavy/Kill/BossKill = ") + .Append(WL.Combat.Reaction.ImpactTier.TierCount[0]).Append("/") + .Append(WL.Combat.Reaction.ImpactTier.TierCount[1]).Append("/") + .Append(WL.Combat.Reaction.ImpactTier.TierCount[2]).Append("/") + .Append(WL.Combat.Reaction.ImpactTier.TierCount[3]) + .Append(" · 초당상한 강등=").Append(WL.Combat.Reaction.ImpactTier.DowngradedCount).Append("\n\n"); + } + + /// 도장을 지우고 1건 판정한다(에디트 모드는 프레임이 멈춰 있어 메모이즈에 걸린다). + static WL.Combat.Reaction.ImpactTierKind Tier(Actor victim, double dmg, bool crit, bool skill, bool kill) + { + WL.Combat.Reaction.ImpactTier.ClearStamp(); + return WL.Combat.Reaction.ImpactTier.Resolve(victim, MakeDinfo(dmg, crit, skill, kill), kill); + } + + static DamageInfo MakeDinfo(double dmg, bool critical, bool skill, bool kill) + { + var d = new DamageInfo(); + d.Beater = s_pc; + d.Damage = dmg; + d.Critical = critical ? eStat.CRI : eStat.None; + d.IsDeadByThisDamage = kill; + if (skill) + { + var pd = new ProjectileData(); + pd.m_SkillListTableData = new SkillListTableData(); + d.Beater_pd = pd; + } + return d; + } + + // ───────────────────────────────────────────────────────────────────────── + // B. PC 피격 — MPB 플래시 + 넉백 + // ───────────────────────────────────────────────────────────────────────── + static void SectionB(System.Text.StringBuilder sb) + { + sb.Append("## B. PC 피격 (가짜 Damaged)\n"); + WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); + var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; + if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } + + var before = s_pcGo.transform.position; + + // ① 무적 피격 = 걸러진다(기준서 8-1 · 813tj HitVignette 의 ignoreInvincibleHits 와 같은 기본값) + WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 0d, true); + sb.Append("무적 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) + .Append(" (기대 0 · pcHitSkipWhenInvincible=").Append(st.pcHitSkipWhenInvincible).Append(")\n"); + + // ② 보스 피격 = 림라이트 + 넉백 + WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); + bool blockOn = WL.Combat.Reaction.PcHitReaction.HasAnyBlock(s_pc); + sb.Append("보스 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) + .Append(" · MPB 붙음=").Append(blockOn) + .Append(" · 넉백=").Append(WL.Combat.Reaction.PcHitReaction.KnockbackCount) + .Append(" · NavMesh 스킵=").Append(WL.Combat.Reaction.PcHitReaction.SkippedNoNavMesh).Append("\n"); + sb.Append("넉백 벡터 = ").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.ToString("F3")) + .Append(" · 거리=").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance.ToString("F3")) + .Append("m (SO ").Append(st.pcHitKnockbackDistance).Append("m) · agentMove=") + .Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent).Append("\n"); + + sb.Append("방향 검사(계획 벡터): 가해자 반대(-X) 기대 · x=") + .Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x.ToString("F3")) + .Append(" → ").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x < 0f ? "PASS" : "FAIL").Append("\n"); + // 위 가짜 PC 는 **비활성**이라 Tick 의 생산 가드(!isActiveAndEnabled)가 이동을 즉시 접는다 → 실이동은 B-2 에서 잰다. + float dur = UnityEngine.Mathf.Max(0.01f, st.pcHitKnockbackSeconds); + for (int i = 0; i < 10; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(dur / 10f); + sb.Append("비활성 PC 실이동 = ").Append((s_pcGo.transform.position - before).magnitude.ToString("F3")) + .Append("m (기대 0 — 비활성 액터는 밀지 않는다 = 생산 가드)\n"); + + // 림라이트 만료 — flashSeconds 를 넘겨 틱하면 off 되고 MPB 가 떨어진다. + double t0 = UnityEngine.Time.realtimeSinceStartupAsDouble; + int guard = 0; + while (WL.Combat.Reaction.PcHitReaction.FlashOffCount == 0 && guard++ < 2000 && + UnityEngine.Time.realtimeSinceStartupAsDouble - t0 < st.pcHitFlashSeconds + 1.0) + WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); + bool blockOff = WL.Combat.Reaction.PcHitReaction.HasAnyBlock(s_pc); + sb.Append("만료 → flashOff=").Append(WL.Combat.Reaction.PcHitReaction.FlashOffCount) + .Append(" · MPB 잔류=").Append(blockOff).Append(" (기대 false)\n"); + sb.Append("B-1 판정: ").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount == 1 && + WL.Combat.Reaction.PcHitReaction.FlashOffCount >= 1 && !blockOff && + WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x < 0f ? "PASS" : "FAIL").Append("\n"); + SectionB2(sb, st); + sb.Append("\n"); + } + + // B-2. 활성 PC 로 넉백 **실이동** 측정(에이전트 경로 · transform 경로 둘 다) + static void SectionB2(System.Text.StringBuilder sb, WL.Combat.Reaction.WLImpactTierSettings st) + { + sb.Append("### B-2. 넉백 실이동 (활성 PC · 최소 구성)\n"); + int errBefore = s_log.Count; + UnityEngine.GameObject go = null; + try + { + go = new UnityEngine.GameObject("[811gh] pc2"); + go.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + go.SetActive(false); + UnityEngine.AI.NavMeshHit nh; + if (UnityEngine.AI.NavMesh.SamplePosition(UnityEngine.Vector3.zero, out nh, 2000f, UnityEngine.AI.NavMesh.AllAreas)) + go.transform.position = nh.position; + var cube = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); + cube.transform.SetParent(go.transform, false); + var agent = go.AddComponent(); // Actor.Awake 가 m_NavMeshAgent.speed 를 읽는다 + var pc2 = go.AddComponent(); + pc2.m_Role = eRole.PC; pc2.m_SubRole = eSubRol.None; + go.SetActive(true); // 여기서 원본 Actor.Awake 가 돈다 + s_attackerGo.transform.position = go.transform.position + new UnityEngine.Vector3(1f, 0f, 0f); + + // ① 에이전트 경로(SO 기본 pcHitKnockbackUseAgentMove=1) + WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); + var p0 = go.transform.position; + WL.Combat.Reaction.PcHitReaction.ProbeDamaged(pc2, s_attacker, 120d, false); + bool usedAgent = WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent; + float plan = WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance; + float step = UnityEngine.Mathf.Max(0.01f, st.pcHitKnockbackSeconds) / 10f; + for (int i = 0; i < 12; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(step); + var mv1 = go.transform.position - p0; + sb.Append("① agentMove=").Append(usedAgent).Append(" · isOnNavMesh=").Append(agent.isOnNavMesh) + .Append(" · 계획=").Append(plan.ToString("F3")).Append("m · 실이동=").Append(mv1.ToString("F3")) + .Append(" 길이=").Append(mv1.magnitude.ToString("F3")).Append("m\n"); + + // ② transform 경로(에이전트를 끄면 SO 스위치 pcHitKnockbackUseAgentMove=0 과 같은 분기) + agent.enabled = false; + WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); + var p1 = go.transform.position; + WL.Combat.Reaction.PcHitReaction.ProbeDamaged(pc2, s_attacker, 120d, false); + float plan2 = WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance; + for (int i = 0; i < 12; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(step); + var mv2 = go.transform.position - p1; + sb.Append("② agentMove=").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent) + .Append(" · 계획=").Append(plan2.ToString("F3")).Append("m · 실이동=").Append(mv2.ToString("F3")) + .Append(" 길이=").Append(mv2.magnitude.ToString("F3")).Append("m\n"); + sb.Append("B-2 판정(둘 중 하나라도 계획 거리 ±10% 이내 · 방향 -X): ") + .Append(((Near(mv1.magnitude, plan) && mv1.x < 0f) || (Near(mv2.magnitude, plan2) && mv2.x < 0f)) ? "PASS" : "FAIL").Append("\n"); + } + catch (System.Exception ex) { sb.Append("B-2 예외: ").Append(ex.Message).Append("\n"); } + finally { if (go != null) UnityEngine.Object.DestroyImmediate(go); } + sb.Append("B-2 구간 Error/Exception 로그 = ").Append(s_log.Count - errBefore) + .Append(" (원본 Actor.Awake 를 최소 구성으로 돌린 구간 · 그 외 구간과 분리해 센다)\n"); + } + + static bool Near(float a, float b) { return b > 0.0001f && UnityEngine.Mathf.Abs(a - b) <= b * 0.1f; } + + // ───────────────────────────────────────────────────────────────────────── + // C. 대시 잔상 + // ───────────────────────────────────────────────────────────────────────── + static void SectionC(System.Text.StringBuilder sb) + { + sb.Append("## C. 대시 잔상 (가짜 Dash began)\n"); + WL.Combat.Reaction.DashAfterImage.ResetDiagnostics(); + WL.Combat.Core.EffectBudget.ResetAll(); + var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; + if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } + + WL.Combat.Reaction.DashAfterImage.ProbeDashBegan(s_pc); + sb.Append("예약 = ").Append(WL.Combat.Reaction.DashAfterImage.PendingCount) + .Append(" (SO ghostCount=").Append(st.ghostCount).Append(")\n"); + WL.Combat.Reaction.DashAfterImage.ProbeFlushPending(s_pc); + + sb.Append("스폰 = ").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount) + .Append(" · 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount) + .Append(" · 풀=").Append(WL.Combat.Reaction.DashAfterImage.PoolSize) + .Append(" · BakeMesh=").Append(WL.Combat.Reaction.DashAfterImage.BakeCount).Append("\n"); + sb.Append("스킵: 예산=").Append(WL.Combat.Reaction.DashAfterImage.SkippedBudget) + .Append(" 화면밖=").Append(WL.Combat.Reaction.DashAfterImage.SkippedOffscreen) + .Append(" 렌더러없음=").Append(WL.Combat.Reaction.DashAfterImage.SkippedNoRenderer) + .Append(" 셰이더없음=").Append(WL.Combat.Reaction.DashAfterImage.SkippedNoShader) + .Append(" 풀참=").Append(WL.Combat.Reaction.DashAfterImage.SkippedPoolFull).Append("\n"); + sb.Append("머티리얼 = ").Append(WL.Combat.Reaction.DashAfterImage.MaterialReady ? WL.Combat.Reaction.DashAfterImage.MaterialShaderName : "(없음)") + .Append(" · 런타임 머티리얼 수=").Append(WL.Combat.Reaction.DashAfterImage.MaterialReady ? 1 : 0) + .Append(" · 색=").Append(WL.Combat.Reaction.DashAfterImage.LastColor.ToString("F2")).Append("\n"); + sb.Append("EffectBudget(AfterImage) 사용=").Append(WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage)) + .Append("/").Append(WL.Combat.Core.EffectBudget.Limit(WL.Combat.Core.EffectBudgetKind.AfterImage)) + .Append(" · 거부=").Append(WL.Combat.Core.EffectBudget.DeniedCount).Append("\n"); + + // 페이드 → 반환 + double t0 = UnityEngine.Time.realtimeSinceStartupAsDouble; + int guard = 0; + while (WL.Combat.Reaction.DashAfterImage.ActiveCount > 0 && guard++ < 4000 && + UnityEngine.Time.realtimeSinceStartupAsDouble - t0 < st.ghostFadeSeconds + 1.0) + WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); + sb.Append("페이드 후 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount) + .Append(" · 반환=").Append(WL.Combat.Reaction.DashAfterImage.ReleasedCount) + .Append(" · 예산 잔여=").Append(WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage)).Append("\n"); + int expect = UnityEngine.Mathf.Min(st.ghostCount, WL.Combat.Core.EffectBudget.Limit(WL.Combat.Core.EffectBudgetKind.AfterImage)); + sb.Append("판정: 스폰 ").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount).Append("/기대 ").Append(expect) + .Append(" · 활성 0 · 예산 0 → ") + .Append(WL.Combat.Reaction.DashAfterImage.SpawnCount == expect && + WL.Combat.Reaction.DashAfterImage.ActiveCount == 0 && + WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage) == 0 ? "PASS" : "FAIL").Append("\n\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // D. GC 100회 + // ───────────────────────────────────────────────────────────────────────── + static void SectionD(System.Text.StringBuilder sb) + { + sb.Append("## D. GC (100회 반복 · GC.GetAllocatedBytesForCurrentThread)\n"); + var dinfo = MakeDinfo(100d, false, false, false); + + // 워밍업 — 첫 호출의 캐시(상태 dict · MPB · 셰이더 실측 · 풀·머티리얼)는 제외한다. + for (int i = 0; i < 5; i++) { WL.Combat.Reaction.ImpactTier.ClearStamp(); WL.Combat.Reaction.ImpactTier.Resolve(s_mob, dinfo, false); } + long a0 = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) { WL.Combat.Reaction.ImpactTier.ClearStamp(); WL.Combat.Reaction.ImpactTier.Resolve(s_mob, dinfo, false); } + long a1 = System.GC.GetAllocatedBytesForCurrentThread(); + sb.Append("ImpactTier.Resolve ×100 = ").Append(a1 - a0).Append(" B\n"); + + for (int i = 0; i < 5; i++) { WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); WL.Combat.Reaction.ImpactRunner.ForceTick(0.5f); } + long b0 = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) { WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); WL.Combat.Reaction.ImpactRunner.ForceTick(0.5f); } + long b1 = System.GC.GetAllocatedBytesForCurrentThread(); + sb.Append("PcHitReaction 피격+틱 ×100 = ").Append(b1 - b0).Append(" B\n"); + + long c0 = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); + long c1 = System.GC.GetAllocatedBytesForCurrentThread(); + sb.Append("ImpactRunner.ForceTick ×100 = ").Append(c1 - c0).Append(" B\n"); + + // 잔상 — 풀·머티리얼이 이미 만들어진 뒤(2회차부터)의 스폰·반환 할당 + for (int i = 0; i < 3; i++) { WL.Combat.Reaction.DashAfterImage.ForceSpawn(s_pc); WL.Combat.Reaction.DashAfterImage.ProbeExpireAll(); } + long d0 = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) { WL.Combat.Reaction.DashAfterImage.ForceSpawn(s_pc); WL.Combat.Reaction.DashAfterImage.ProbeExpireAll(); } + long d1 = System.GC.GetAllocatedBytesForCurrentThread(); + sb.Append("DashAfterImage 스폰+반환 ×100 = ").Append(d1 - d0).Append(" B (BakeMesh 내부 할당 포함)\n"); + sb.Append("판정(티어·틱 0 B): ").Append((a1 - a0) == 0 && (c1 - c0) == 0 ? "PASS" : "FAIL").Append("\n\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // E. C8 롤백 + // ───────────────────────────────────────────────────────────────────────── + static void SectionE(System.Text.StringBuilder sb) + { + sb.Append("## E. C8 롤백 (RuntimeDisabled = 에셋 enabled 0 과 같은 Enabled 게이트)\n"); + WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = true; + WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); + WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); + WL.Combat.Reaction.DashAfterImage.ResetDiagnostics(); + WL.Combat.Reaction.DashAfterImage.RestoreAll(); + + sb.Append("ImpactTier.Active = ").Append(WL.Combat.Reaction.ImpactTier.Active).Append(" (기대 false)\n"); + var t = Tier(s_bossMob, 100d, true, true, true); + sb.Append("보스킬 상황 판정 = ").Append(t).Append(" (기대 Light = 티어 미사용)\n"); + + WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); + sb.Append("PC 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) + .Append(" 넉백=").Append(WL.Combat.Reaction.PcHitReaction.KnockbackCount).Append(" (기대 0/0)\n"); + + WL.Combat.Reaction.DashAfterImage.ProbeDashBegan(s_pc); + WL.Combat.Reaction.DashAfterImage.ProbeFlushPending(s_pc); + sb.Append("대시 → 스폰=").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount) + .Append(" 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount).Append(" (기대 0/0)\n"); + + // 기존 2단(WLHitFeel) 값이 그대로 살아 있는가 + var feel = WL.Feel.WLHitFeel.Settings; + if (feel != null) + sb.Append("기존 2단 유지: hitStop=").Append(feel.hitStopDuration).Append("/").Append(feel.hitStopKillDuration) + .Append(" · shakeKillMul=").Append(feel.shakeKillMultiplier).Append(" · hitStopMax=").Append(feel.hitStopMax).Append("\n"); + + bool pass = !WL.Combat.Reaction.ImpactTier.Active && + t == WL.Combat.Reaction.ImpactTierKind.Light && + WL.Combat.Reaction.PcHitReaction.FlashOnCount == 0 && + WL.Combat.Reaction.PcHitReaction.KnockbackCount == 0 && + WL.Combat.Reaction.DashAfterImage.SpawnCount == 0; + sb.Append("판정: ").Append(pass ? "PASS" : "FAIL").Append("\n"); + WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = false; + sb.Append("\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // F. 소스 확인 + // ───────────────────────────────────────────────────────────────────────── + static void SectionF(System.Text.StringBuilder sb) + { + sb.Append("## F. 소스 확인 (원본 훅 0 · 배선)\n"); + string feel = ReadAll(kHitFeel); + string blink = ReadAll(kBlink); + sb.Append("WLHitFeel.cs ImpactTier.Resolve 호출 = ").Append(Has(feel, "ImpactTier.Resolve")).Append("\n"); + sb.Append("WLHitFeel.cs ImpactTier.HitStopSeconds = ").Append(Has(feel, "ImpactTier.HitStopSeconds")).Append("\n"); + sb.Append("WLHitFeel.cs ImpactTier.ShakeMultiplier = ").Append(Has(feel, "ImpactTier.ShakeMultiplier")).Append("\n"); + sb.Append("WLHitFeel.cs ImpactTier.PunchMultiplier = ").Append(Has(feel, "ImpactTier.PunchMultiplier")).Append("\n"); + sb.Append("WLHitFeel.cs 기존 2단 폴백 보존(hitStopKillDuration) = ").Append(Has(feel, "st.hitStopKillDuration")).Append("\n"); + sb.Append("MobHitFlash.cs 점멸 배수 적용(GetBlinkModifiers) = ").Append(Has(blink, "ImpactTier.GetBlinkModifiers")).Append("\n"); + + string[] originals = new string[] { + "Assets/Script/Character/Actor.cs", "Assets/Script/Character/PCActor.cs", + "Assets/Script/Character/MyActor.cs", "Assets/Script/Character/Mob/MobActor.cs" + }; + for (int i = 0; i < originals.Length; i++) + { + string src = ReadAll(originals[i]); + sb.Append(System.IO.Path.GetFileName(originals[i])).Append(": 811gh 문자열 = ") + .Append(src != null && src.Contains("811gh") ? "있음(!)" : "없음").Append("\n"); + } + sb.Append("\n"); + } + + static string ReadAll(string p) { return System.IO.File.Exists(p) ? System.IO.File.ReadAllText(p) : null; } + static string Has(string src, string needle) { return src != null && src.Contains(needle) ? "있음" : "없음(!)"; } +} diff --git a/Assets/WL/Combat/Reaction/DashAfterImage.cs b/Assets/WL/Combat/Reaction/DashAfterImage.cs new file mode 100644 index 000000000..262ddabda --- /dev/null +++ b/Assets/WL/Combat/Reaction/DashAfterImage.cs @@ -0,0 +1,496 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DashAfterImage.cs — 대시 잔상(811g-② · SkinnedMeshRenderer.BakeMesh 고스트 · 원본 훅 0 · 새 셰이더/머티리얼 에셋 0) +// +// PD 지시 #813 · 발주서 WL-811gh §1-3 · 설계안 §C 811g 행 (2026-09-09) +// +// ■ 무엇을 하나 (CombatEvents.Dash 구독 · DashDriver 가 이미 RaiseDashBegan 을 발행한다 — 실측 DashDriver.cs:158) +// 대시 시작 → 0.06 s 간격으로 고스트 3개를 남기고 각각 0.25 s 동안 알파를 0 으로 줄인다. +// 고스트 = PC 의 SkinnedMeshRenderer 를 그 순간 포즈 그대로 BakeMesh 한 정적 메시. +// +// ■ 왜 BakeMesh 인가 / 예산 +// · 스킨 애니메이션을 그 프레임 포즈로 굳혀야 잔상이 '그 자세' 로 남는다. 머티리얼 교체·셰이더 추가가 필요 없다. +// · 메시·GameObject·MaterialPropertyBlock 은 전부 **풀**(기본 6)에서 재사용한다 → 2회차부터 할당 0. +// · BakeMesh 호출 = 대시 1회당 (고스트 수 × SkinnedMeshRenderer 수) · SO 상한(ghostCount · ghostMaxRenderers). +// · 런타임 머티리얼은 **1개**(모든 고스트가 공유 · 색/알파는 MaterialPropertyBlock) → SetPass 증가는 반투명 큐 1개분. +// · EffectBudget(AfterImage · 코어 SO afterImageMax) 경유 · 절두체 밖이면 스폰 자체를 건너뛴다. +// +// ■ 색 (발주서: 클래스 원소 색 · 없으면 흰색) +// ① 최근 시전 스킬의 추가효과(eSkillExtraType · 811i 와 같은 기준) 색 → ② 클래스 ID(Actor.Get_ID()) 색 → ③ 기본(흰색). +// ①·② 표는 SO 에 있고 비어 있으면 자동으로 다음 단계로 내려간다. +// +// ■ 대시가 없는 클래스 +// AttackStepDriver.StepCount(정적 카운터) 변화를 폴링해 공격 전진 시작에도 1개(옵션 SO · 기본 off). +// 폴링이라 AttackStepDriver·원본 파일을 한 줄도 건드리지 않는다. +// +// ■ C8 롤백 — 에셋이 없거나 enabled=0 또는 ghostEnabled=0 이면 구독만 하고 아무 것도 만들지 않는다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class DashAfterImage + { + sealed class Ghost + { + public GameObject root; + public Transform[] parts; + public MeshFilter[] filters; + public MeshRenderer[] renders; + public Mesh[] meshes; + public MaterialPropertyBlock mpb; + public bool active, budget; + public int used; + public float startAt, endAt; + public Color color; + } + + static Ghost[] s_pool; + static Material s_material; + static bool s_materialTried; + static int s_colorPropId = -1; + static bool s_hasColorProp; + + static readonly List s_tmpSmr = new List(8); + static SkinnedMeshRenderer[] s_smr; + static Actor s_smrOwner; + + // ── 예약 스폰(간격을 두고 하나씩) + static Actor s_pendingActor; + static int s_pendingLeft; + static float s_nextSpawnAt; + + // ── 최근 스킬 원소 기억 + static int s_lastElement = -1; + static float s_lastElementAt = -999f; + + // ── AttackStep 폴링 + static int s_lastStepCount = -1; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int SpawnCount, SkippedBudget, SkippedOffscreen, SkippedNoRenderer, SkippedNoShader, SkippedPoolFull, ReleasedCount, PoolCreated, BakeCount, StepSpawnCount; + public static string LastInfo = ""; + public static Color LastColor; + public static int ActiveCount { get { int n = 0; if (s_pool != null) for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && s_pool[i].active) n++; return n; } } + public static int PoolSize { get { return s_pool != null ? s_pool.Length : 0; } } + public static bool MaterialReady { get { return s_material != null; } } + public static string MaterialShaderName { get { return s_material != null && s_material.shader != null ? s_material.shader.name : ""; } } + + static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.Dash.Add(OnDash); + CombatEvents.SkillFired.Add(OnSkillFired); + CombatEvents.SkillCast.Add(OnSkillCast); + Subscribed = true; + } + + // ───────────────────────────────────────── 이벤트 + static void OnDash(in DashEvent e) + { + if (!e.began) return; + var st = St; + if (st == null || !WLImpactTierSettings.Enabled || !st.ghostEnabled) return; + Actor actor = e.actor != null ? (Actor)e.actor : MyValue.MyPC; + if (actor == null) return; + Schedule(st, actor, Mathf.Max(0, st.ghostCount)); + } + + static void OnSkillFired(in SkillFiredEvent e) { RememberElement(e.actor, e.skill); } + static void OnSkillCast(in SkillCastEvent e) { RememberElement(e.actor, e.skill); } + + static void RememberElement(Actor actor, SkillListTableData skill) + { + if (skill == null || actor == null || !actor.IsMainPC()) return; + s_lastElement = (int)skill.e_SkillExtraType; + s_lastElementAt = ImpactRunner.Now; + } + + static void Schedule(WLImpactTierSettings st, Actor actor, int count) + { + if (count <= 0) return; + s_pendingActor = actor; + s_pendingLeft = count; + s_nextSpawnAt = ImpactRunner.Now; // 첫 개는 즉시 + ImpactRunner.Ensure(); + } + + // ───────────────────────────────────────── 틱(ImpactRunner) + internal static void Tick(float now) + { + var st = St; + bool on = st != null && WLImpactTierSettings.Enabled && st.ghostEnabled; + + // ① 예약 스폰 + if (on && s_pendingLeft > 0 && now >= s_nextSpawnAt) + { + Spawn(st, s_pendingActor); + s_pendingLeft--; + s_nextSpawnAt = now + Mathf.Max(0.005f, st.ghostIntervalSeconds); + if (s_pendingLeft <= 0) s_pendingActor = null; + } + else if (!on && s_pendingLeft > 0) { s_pendingLeft = 0; s_pendingActor = null; } + + // ② 공격 전진(대시 없는 클래스) 폴링 — 원본·AttackStepDriver 무수정 + if (on && st.ghostOnAttackStep) + { + int sc = WL.Combat.AttackStepDriver.StepCount; + if (s_lastStepCount < 0) s_lastStepCount = sc; + else if (sc != s_lastStepCount) + { + s_lastStepCount = sc; + if (s_pendingLeft <= 0) { Schedule(st, MyValue.MyPC, Mathf.Max(0, st.ghostAttackStepCount)); StepSpawnCount++; } + } + } + + // ③ 페이드·반환 + if (s_pool == null) return; + for (int i = 0; i < s_pool.Length; i++) + { + var g = s_pool[i]; + if (g == null || !g.active) continue; + if (!on || now >= g.endAt) { Retire(g); continue; } + float k = Mathf.Clamp01((now - g.startAt) / Mathf.Max(0.01f, g.endAt - g.startAt)); + SetAlpha(g, g.color.a * (1f - k)); + } + } + + // ───────────────────────────────────────── 스폰 + static void Spawn(WLImpactTierSettings st, Actor actor) + { + if (actor == null) { SkippedNoRenderer++; return; } + if (st.ghostSkipOffscreen && !EffectBudget.IsInView(actor.Get_position())) { SkippedOffscreen++; return; } + + var smr = GetRenderers(st, actor); + if (smr == null || smr.Length == 0) { SkippedNoRenderer++; return; } + if (!EnsureMaterial(st)) { SkippedNoShader++; return; } + + var g = Rent(st); + if (g == null) { SkippedPoolFull++; return; } + + bool budget = true; + if (st.ghostUseEffectBudget) + { + budget = EffectBudget.TryAcquire(EffectBudgetKind.AfterImage); + if (!budget) { SkippedBudget++; Return(g); return; } + } + g.budget = budget; + + int n = Mathf.Min(smr.Length, g.parts.Length); + int used = 0; + for (int i = 0; i < n; i++) + { + var r = smr[i]; + if (r == null || !r.enabled || r.sharedMesh == null) continue; + r.BakeMesh(g.meshes[used]); + BakeCount++; + g.filters[used].sharedMesh = g.meshes[used]; + var rt = r.transform; + g.parts[used].SetPositionAndRotation(rt.position, rt.rotation); + g.parts[used].localScale = rt.lossyScale; + g.parts[used].gameObject.SetActive(true); + used++; + } + for (int i = used; i < g.parts.Length; i++) g.parts[i].gameObject.SetActive(false); + + if (used == 0) + { + if (g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage); + Return(g); + SkippedNoRenderer++; + return; + } + + g.used = used; + g.color = ResolveColor(st, actor); + LastColor = g.color; + g.startAt = ImpactRunner.Now; + g.endAt = g.startAt + Mathf.Max(0.02f, st.ghostFadeSeconds); + g.active = true; + g.root.SetActive(true); + SetAlpha(g, g.color.a); + SpawnCount++; + LastInfo = "ghost " + used + " part(s)"; + if (Verbose) Debug.Log("[DashAfterImage] 고스트 " + used + "개 파트 · color=" + g.color + " active=" + ActiveCount); + } + + static void Retire(Ghost g) + { + g.active = false; + g.root.SetActive(false); + if (g.budget) { EffectBudget.Release(EffectBudgetKind.AfterImage); g.budget = false; } + ReleasedCount++; + } + + static void Return(Ghost g) { g.active = false; g.root.SetActive(false); } + + // ───────────────────────────────────────── 풀 + static Ghost Rent(WLImpactTierSettings st) + { + EnsurePool(st); + for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && !s_pool[i].active) return s_pool[i]; + return null; + } + + static void EnsurePool(WLImpactTierSettings st) + { + int size = Mathf.Clamp(st.ghostPoolSize, 1, 24); + int parts = Mathf.Clamp(st.ghostMaxRenderers, 1, 8); + if (s_pool != null && s_pool.Length == size && s_pool[0] != null && s_pool[0].parts.Length == parts) return; + DestroyPool(); + s_pool = new Ghost[size]; + for (int i = 0; i < size; i++) s_pool[i] = CreateGhost(st, i, parts); + PoolCreated++; + } + + static Ghost CreateGhost(WLImpactTierSettings st, int index, int parts) + { + var root = new GameObject("__WLDashGhost" + index); + root.hideFlags = HideFlags.DontSave; + if (Application.isPlaying) Object.DontDestroyOnLoad(root); // 에디트 모드(프로브)에서는 경고가 나므로 부르지 않는다 + root.SetActive(false); + var g = new Ghost + { + root = root, + parts = new Transform[parts], + filters = new MeshFilter[parts], + renders = new MeshRenderer[parts], + meshes = new Mesh[parts], + mpb = new MaterialPropertyBlock() + }; + for (int i = 0; i < parts; i++) + { + var go = new GameObject("part" + i); + go.transform.SetParent(root.transform, false); + go.SetActive(false); + g.parts[i] = go.transform; + g.filters[i] = go.AddComponent(); + var mr = go.AddComponent(); + mr.sharedMaterial = s_material; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + g.renders[i] = mr; + var mesh = new Mesh(); + mesh.name = "__WLDashGhostMesh" + index + "_" + i; + mesh.hideFlags = HideFlags.DontSave; + mesh.MarkDynamic(); + g.meshes[i] = mesh; + } + return g; + } + + static void SetAlpha(Ghost g, float alpha) + { + if (!s_hasColorProp) return; + var c = g.color; c.a = alpha; + g.mpb.Clear(); + g.mpb.SetColor(s_colorPropId, c); + for (int i = 0; i < g.used; i++) if (g.renders[i] != null) g.renders[i].SetPropertyBlock(g.mpb); + } + + // ───────────────────────────────────────── 머티리얼(런타임 1개 · 에셋 0) + static bool EnsureMaterial(WLImpactTierSettings st) + { + if (s_material != null) return true; + if (s_materialTried) return false; + s_materialTried = true; + + Shader sh = null; + var names = st.ghostShaderNames; + if (names != null) + for (int i = 0; i < names.Length && sh == null; i++) + if (!string.IsNullOrEmpty(names[i])) sh = Shader.Find(names[i]); + if (sh == null) { if (Verbose) Debug.Log("[DashAfterImage] 셰이더 후보를 못 찾음 — 잔상 비활성"); return false; } + + var m = new Material(sh); + m.name = "__WLDashGhostMat"; + m.hideFlags = HideFlags.DontSave; + // URP Unlit 반투명 세팅(런타임) — 이 값들은 렌더링 배관이라 SO 튜닝 값이 아니다. + if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f); + if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f); + if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f); + if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f); + m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + m.DisableKeyword("_ALPHATEST_ON"); + m.SetOverrideTag("RenderType", "Transparent"); + m.renderQueue = st.ghostRenderQueue > 0 ? st.ghostRenderQueue : 3000; + + s_hasColorProp = false; + var props = st.ghostColorProperties; + if (props != null) + for (int i = 0; i < props.Length; i++) + if (!string.IsNullOrEmpty(props[i]) && sh.FindPropertyIndex(props[i]) >= 0) + { s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; } + + s_material = m; + // 풀이 먼저 만들어졌으면 머티리얼을 붙여 준다. + if (s_pool != null) + for (int i = 0; i < s_pool.Length; i++) + { + var g = s_pool[i]; + if (g == null) continue; + for (int j = 0; j < g.renders.Length; j++) if (g.renders[j] != null) g.renders[j].sharedMaterial = s_material; + } + if (Verbose) Debug.Log("[DashAfterImage] 고스트 머티리얼 = " + sh.name + " colorProp=" + s_hasColorProp); + return true; + } + + // ───────────────────────────────────────── 렌더러 캐시 + static SkinnedMeshRenderer[] GetRenderers(WLImpactTierSettings st, Actor actor) + { + if (s_smrOwner == actor && s_smr != null) + { + bool ok = true; + for (int i = 0; i < s_smr.Length; i++) if (s_smr[i] == null) { ok = false; break; } + if (ok) return s_smr; + } + s_tmpSmr.Clear(); + actor.GetComponentsInChildren(true, s_tmpSmr); + int max = Mathf.Clamp(st.ghostMaxRenderers, 1, 8); + int n = Mathf.Min(s_tmpSmr.Count, max); + s_smr = new SkinnedMeshRenderer[n]; + for (int i = 0; i < n; i++) s_smr[i] = s_tmpSmr[i]; + s_tmpSmr.Clear(); + s_smrOwner = actor; + return s_smr; + } + + // ───────────────────────────────────────── 색 + static Color ResolveColor(WLImpactTierSettings st, Actor actor) + { + Color c = st.ghostColorDefault; + bool found = false; + + if (st.ghostUseSkillElement && s_lastElement > 0 && + ImpactRunner.Now - s_lastElementAt <= Mathf.Max(0f, st.ghostElementMemorySeconds)) + { + var arr = st.ghostElementColors; + if (arr != null) + for (int i = 0; i < arr.Length; i++) + if (arr[i].extraType == s_lastElement) { c = arr[i].color; found = true; break; } + } + + if (!found) + { + var arr = st.ghostClassColors; + if (arr != null) + { + int classId = actor.Get_ID(); + for (int i = 0; i < arr.Length; i++) + if (arr[i].classId == classId) { c = arr[i].color; found = true; break; } + } + } + + c.a = Mathf.Clamp01(st.ghostStartAlpha); + return c; + } + + // ───────────────────────────────────────── 정리 + /// 진행 중인 고스트를 전부 끄고 예산을 돌려준다(러너 파괴 · 앱 종료 · 프로브 정리). 풀은 유지. + public static void RestoreAll() + { + s_pendingLeft = 0; s_pendingActor = null; + if (s_pool == null) return; + for (int i = 0; i < s_pool.Length; i++) + { + var g = s_pool[i]; + if (g != null && g.active) Retire(g); + } + } + + /// 씬 전환: 고스트를 끄고 렌더러 캐시(파괴된 Actor 참조)를 버린다. 풀 오브젝트는 DontDestroyOnLoad 라 유지. + public static void ClearAll() + { + RestoreAll(); + s_smr = null; s_smrOwner = null; s_lastStepCount = -1; + } + + /// 풀·머티리얼·메시를 전부 파괴한다(프로브 정리 · 도메인 리로드 전). + public static void DestroyPool() + { + if (s_pool != null) + { + for (int i = 0; i < s_pool.Length; i++) + { + var g = s_pool[i]; + if (g == null) continue; + if (g.active && g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage); + for (int j = 0; j < g.meshes.Length; j++) if (g.meshes[j] != null) DestroySafe(g.meshes[j]); + if (g.root != null) DestroySafe(g.root); + } + s_pool = null; + } + } + + static void DestroySafe(Object o) + { + if (o == null) return; + if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o); + } + + /// 프로브용: 강제로 즉시 1개 남긴다(예약 없이). + public static void ForceSpawn(Actor actor) + { + var st = St; + if (st == null) return; + Spawn(st, actor != null ? actor : MyValue.MyPC); + } + + /// 프로브용: 예약 스폰 남은 수. + public static int PendingCount { get { return s_pendingLeft; } } + + /// + /// 프로브용 — 가짜 Dash(began) 를 구독자 경로 그대로 흘린다. + /// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독이 없으므로 핸들러를 직접 부른다. + /// + public static void ProbeDashBegan(PCActor actor) + { + var e = new DashEvent { actor = actor, began = true, distance = 0f, endedInAttack = false, time = Time.unscaledTime, frame = Time.frameCount }; + OnDash(in e); + } + + /// 프로브용 — 예약된 스폰을 간격 대기 없이 전부 즉시 처리한다(에디트 모드에는 프레임이 없다). + public static void ProbeFlushPending(Actor actor) + { + var st = St; + if (st == null) return; + int guard = 0; + while (s_pendingLeft > 0 && guard++ < 32) + { + Spawn(st, s_pendingActor != null ? s_pendingActor : actor); + s_pendingLeft--; + } + s_pendingActor = null; + } + + /// 프로브용 — 지금 살아 있는 고스트를 강제로 만료시킨다(페이드 끝 상태 확인). + public static void ProbeExpireAll() + { + if (s_pool == null) return; + for (int i = 0; i < s_pool.Length; i++) + { + var g = s_pool[i]; + if (g != null && g.active) Retire(g); + } + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + SpawnCount = SkippedBudget = SkippedOffscreen = SkippedNoRenderer = SkippedNoShader = SkippedPoolFull = ReleasedCount = PoolCreated = BakeCount = StepSpawnCount = 0; + LastInfo = ""; s_lastStepCount = -1; + } + } +} diff --git a/Assets/WL/Combat/Reaction/DashAfterImage.cs.meta b/Assets/WL/Combat/Reaction/DashAfterImage.cs.meta new file mode 100644 index 000000000..4a51d94e4 --- /dev/null +++ b/Assets/WL/Combat/Reaction/DashAfterImage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b984a9d7c124984b90b4ee40ecc807c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/ImpactRunner.cs b/Assets/WL/Combat/Reaction/ImpactRunner.cs new file mode 100644 index 000000000..0fc944376 --- /dev/null +++ b/Assets/WL/Combat/Reaction/ImpactRunner.cs @@ -0,0 +1,82 @@ +// ───────────────────────────────────────────────────────────────────────────── +// ImpactRunner.cs — 811gh 전용 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음 · GC 0) +// +// PD 지시 #813 · 발주서 WL-811gh (2026-09-09) +// +// ■ 왜 WLReactionRunner 에 얹지 않았나 +// 기존 러너(__WLHitFeel · __WLTimeScaleArbiter · __WLReaction · LevelUpBurstRunner · LootBurstRunner)와 같은 패턴을 +// 하나 더 두는 것이지 새 Manager/Singleton 패러다임이 아니다. WLReactionRunner 는 811def 가 만들고 811i(진행 중)도 +// 같은 파일에 틱을 붙일 가능성이 커서, 같은 줄을 고치면 병합 충돌이 난다 — **파일을 나눠 충돌을 0** 으로 만든다. +// +// ■ 무엇을 하나 +// PcHitReaction(림라이트 만료 · 넉백 진행) · DashAfterImage(예약 스폰 · 페이드 · 공격전진 폴링) 틱. +// 파괴·비활성·앱 종료 시 MPB 원복 + 잔상 예산 반환. 씬 전환 시 두 시스템의 상태를 비운다(파괴된 Actor 참조 방지). +// +// ■ 에디트 모드 +// 프로브가 Play 없이 검증할 수 있게 ForceTick() 을 둔다(러너 오브젝트 없이 같은 틱 1회). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace WL.Combat.Reaction +{ + public sealed class ImpactRunner : MonoBehaviour + { + static ImpactRunner s_runner; + static bool s_sceneHooked; + + public static bool Exists { get { return s_runner != null; } } + + public static void Ensure() + { + if (s_runner != null) return; + if (!Application.isPlaying) return; // 에디트 모드에서는 오브젝트를 만들지 않는다(프로브는 ForceTick 을 쓴다) + var go = new GameObject("__WLImpact"); + go.hideFlags = HideFlags.DontSave; + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; } + } + + static void OnSceneChanged(Scene from, Scene to) + { + PcHitReaction.ClearAll(); + DashAfterImage.ClearAll(); + } + + // ── 에디트 모드 시뮬레이션 시계 + // 에디트 모드에서는 한 번의 스크립트 호출 안에서 Time.unscaledTime 이 **멈춰 있다** → + // ForceTick 이 시계를 직접 밀어 준다. Play 에서는 s_simActive 가 false 라 항상 Time.unscaledTime 이다. + static float s_simNow; + static bool s_simActive; + + /// 연출 타이밍의 기준 시각. Play = Time.unscaledTime · 프로브(ForceTick) = 시뮬레이션 시계. + public static float Now { get { return s_simActive ? s_simNow : Time.unscaledTime; } } + + void Update() + { + s_simActive = false; + float now = Time.unscaledTime; + PcHitReaction.Tick(now, Time.unscaledDeltaTime); + DashAfterImage.Tick(now); + } + + /// 프로브(에디트 모드)용 — 러너 없이 같은 틱을 1회 돌리고 시뮬레이션 시계를 delta 만큼 민다. + public static void ForceTick(float unscaledDelta) + { + if (!s_simActive) { s_simActive = true; s_simNow = Time.unscaledTime; } + s_simNow += unscaledDelta; + PcHitReaction.Tick(s_simNow, unscaledDelta); + DashAfterImage.Tick(s_simNow); + } + + /// 프로브용 — 시뮬레이션 시계를 끄고 실제 시간으로 되돌린다. + public static void ResetForceClock() { s_simActive = false; s_simNow = 0f; } + + void OnDisable() { PcHitReaction.RestoreAll(); DashAfterImage.RestoreAll(); } + void OnApplicationQuit() { PcHitReaction.RestoreAll(); DashAfterImage.RestoreAll(); } + } +} diff --git a/Assets/WL/Combat/Reaction/ImpactRunner.cs.meta b/Assets/WL/Combat/Reaction/ImpactRunner.cs.meta new file mode 100644 index 000000000..a1cf1c159 --- /dev/null +++ b/Assets/WL/Combat/Reaction/ImpactRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6130c1ec1faa415b9cc6e317d7a964d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/ImpactTier.cs b/Assets/WL/Combat/Reaction/ImpactTier.cs new file mode 100644 index 000000000..e42cc1067 --- /dev/null +++ b/Assets/WL/Combat/Reaction/ImpactTier.cs @@ -0,0 +1,267 @@ +// ───────────────────────────────────────────────────────────────────────────── +// ImpactTier.cs — 임팩트 티어 리졸버(811h · 정적 · GC 0 · 원본 훅 0) +// +// PD 지시 #813 · 발주서 WL-811gh §1-1 · 설계안 §C 811h 행 (2026-09-09) +// +// ■ 무엇을 하나 +// HitConfirmed 1건을 Light / Heavy / Kill / BossKill 4단으로 분류하고, +// 티어별 값(히트스톱 ms · 셰이크 배수 · 펀치 배수 · 점멸 색/시간 배수 · 초당 상한)을 **한 곳에서** 공급한다. +// 소비자 = WLHitFeel(히트스톱·셰이크·펀치) · MobHitFlash(점멸 강도). 둘 다 같은 티어를 본다. +// +// ■ 왜 「구독 + 메모이즈」 인가 +// WLHitFeel 과 MobHitFlash 는 둘 다 HitConfirmed 구독자다. 어느 쪽이 먼저 불릴지는 정해져 있지 않은데 +// 티어는 둘 다 같아야 한다. 그래서 ImpactTier 가 **SubsystemRegistration**(AfterAssembliesLoaded 보다 먼저)에서 +// 구독해 목록 0번을 차지하고, 이벤트의 authoritative 한 isKill 로 티어를 먼저 확정해 (victim, frame) 로 도장을 찍는다. +// 두 소비자의 Resolve(...) 는 그 도장을 그대로 읽는다(재계산 0 · 평균 링버퍼 중복 적재 0). +// 코어가 꺼져 있으면(RaiseHitConfirmed 가 Dispatch 하지 않는 C8 경로) 도장이 없으므로 호출측 힌트로 그 자리에서 계산한다. +// +// ■ GC 0 +// 구조체·기본형만 쓴다. 평균은 사전 할당 링버퍼(float[]). 진단 문자열은 **읽을 때만** 만든다(TimeScaleArbiter FIX-1 선례). +// verboseLog 가 켜졌을 때만 Debug.Log 를 부른다(문자열 결합도 그 안에서만). +// +// ■ 초당 상한(발주서 「티어별 동시 상한」) +// 히트스톱·셰이크는 구조상 동시 1개다(TimeScaleArbiter 는 층 1개 · 셰이커는 오브젝트 1개). +// 그래서 '동시' 상한은 **초당 상한**으로 실현한다 — 근접 스윙 1회가 군집 5~7 마리를 같은 프레임에 때리므로 +// Heavy/Kill 폭주를 여기서 막는다. 상한을 넘긴 타격은 Light 로 강등한다(연출을 통째로 버리지 않는다). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class ImpactTier + { + // ── 티어 도장(구독자가 먼저 찍는다) ───────────────────────────────── + static Actor s_stampVictim; + static int s_stampFrame = -1; + static ImpactTierKind s_stampTier; + + // ── 최근 피해 평균(링버퍼 · 사전 할당 · GC 0) + static float[] s_samples = new float[64]; + static int s_sampleHead, s_sampleCount; + static float s_sampleSum; + + // ── 티어별 초당 상한(마지막 발동 시각 · 남은 토큰) + static readonly float[] s_lastFire = new float[4]; + static readonly int[] s_fired = new int[4]; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static ImpactTierKind LastTier; + public static int LastTierIndex { get { return (int)LastTier; } } + public static readonly int[] TierCount = new int[4]; + public static int DowngradedCount, ResolvedCount, StampHitCount; + public static float LastDamage, LastAverage; + public static bool LastCritical, LastSkill, LastKill, LastBoss; + + /// 최근 평균 피해(표본이 모자라면 -1). + public static float Average { get { return s_sampleCount >= MinSamples ? s_sampleSum / s_sampleCount : -1f; } } + + static int MinSamples + { + get { var st = St; return st == null ? 8 : Mathf.Clamp(st.damageAverageSamples, 2, s_samples.Length); } + } + + static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } + + /// 티어 시스템이 살아 있는가(에셋 · enabled · RuntimeDisabled · tierEnabled). + public static bool Active + { + get { var st = St; return st != null && st.enabled && !WLImpactTierSettings.RuntimeDisabled && st.tierEnabled; } + } + + /// 에셋은 살아 있는가(티어 판정은 꺼도 PC 피격·잔상은 돈다). + public static bool AssetEnabled { get { return WLImpactTierSettings.Enabled; } } + + // ───────────────────────────────────────── 구독(목록 0번 확보) + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void Register() + { + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + Subscribed = true; + } + + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!Active || e.victim == null) return; + s_stampTier = Compute(e.victim, (float)e.damage, e.critical, IsSkillHit(e.projectile), e.isKill, e.byMainPC); + s_stampVictim = e.victim; + s_stampFrame = Time.frameCount; + } + + // ───────────────────────────────────────── 소비자 진입점 + /// + /// 이 타격의 티어. 구독자가 이미 확정했으면(같은 victim · 같은 프레임) 그 값을 그대로 돌려준다. + /// 코어 비활성(Dispatch 없음) 경로에서는 호출측 힌트로 그 자리에서 계산한다. + /// + public static ImpactTierKind Resolve(Actor victim, DamageInfo dinfo, bool killHint) + { + if (!Active || victim == null) return ImpactTierKind.Light; + if (s_stampVictim == victim && s_stampFrame == Time.frameCount) { StampHitCount++; LastTier = s_stampTier; return s_stampTier; } + + float dmg = dinfo != null ? (float)dinfo.Damage : 0f; + bool crit = dinfo != null && dinfo.Critical != eStat.None; + bool skill = dinfo != null && IsSkillHit(dinfo.Beater_pd); + bool byPC = dinfo != null && dinfo.Beater != null && dinfo.Beater.IsMainPC(); + var t = Compute(victim, dmg, crit, skill, killHint, byPC); + s_stampTier = t; s_stampVictim = victim; s_stampFrame = Time.frameCount; + return t; + } + + static bool IsSkillHit(ProjectileData pd) + { + // 근접 평타도 0.02 s 박스 '투사체' 라 pd != null 만으로는 전부 스킬이 된다 → + // CombatEvents.RaiseHitboxHit 의 isSkill 과 같은 식(스킬 테이블이 붙었는가)으로 본다. + return pd != null && pd.m_SkillListTableData != null; + } + + // ───────────────────────────────────────── 판정 + static ImpactTierKind Compute(Actor victim, float damage, bool critical, bool skill, bool kill, bool byMainPC) + { + var st = St; + LastDamage = damage; LastCritical = critical; LastSkill = skill; LastKill = kill; + + if (st.onlyByMainPC && !byMainPC) { LastBoss = false; return Finish(st, ImpactTierKind.Light); } + + bool boss = st.bossKillEnabled && + (victim.IsSubRole(eSubRol.Boss) || (st.bossKillIncludesElite && victim.IsSubRole(eSubRol.Elite))); + LastBoss = boss; + + ImpactTierKind tier; + if (kill) tier = boss ? ImpactTierKind.BossKill : ImpactTierKind.Kill; + else if (IsHeavy(st, damage, critical, skill)) tier = ImpactTierKind.Heavy; + else tier = ImpactTierKind.Light; + + // 평균은 **평타(비킬·비크리)** 만 적재한다 — 크리/스킬/킬을 넣으면 기준선이 스스로 올라가 Heavy 판정이 사라진다. + if (!kill && !critical && !skill && damage > 0f) PushSample(st, damage); + + return Finish(st, tier); + } + + static bool IsHeavy(WLImpactTierSettings st, float damage, bool critical, bool skill) + { + if (st.heavyOnCritical && critical) return true; + if (st.heavyOnSkill && skill) return true; + if (st.heavyDamageAverageMultiplier > 0f) + { + float avg = Average; + LastAverage = avg; + if (avg > 0f && damage >= avg * st.heavyDamageAverageMultiplier) return true; + } + return false; + } + + /// 초당 상한 적용 후 최종 티어를 확정하고 카운터를 남긴다. + static ImpactTierKind Finish(WLImpactTierSettings st, ImpactTierKind tier) + { + if (tier != ImpactTierKind.Light && !TryConsume(st, tier)) + { + DowngradedCount++; + tier = ImpactTierKind.Light; + } + LastTier = tier; + TierCount[(int)tier]++; + ResolvedCount++; + if (st.verboseLog) + Debug.Log("[ImpactTier] " + tier + " dmg=" + LastDamage.ToString("F0") + " avg=" + LastAverage.ToString("F0") + + " crit=" + LastCritical + " skill=" + LastSkill + " kill=" + LastKill + " boss=" + LastBoss); + return tier; + } + + static bool TryConsume(WLImpactTierSettings st, ImpactTierKind tier) + { + int i = (int)tier; + float max = Get(st.tierMaxPerSecond, i, 0f); + if (max <= 0f) return true; + float now = Time.unscaledTime; + if (now - s_lastFire[i] >= 1f) { s_lastFire[i] = now; s_fired[i] = 0; } + if (s_fired[i] >= Mathf.CeilToInt(max)) return false; + s_fired[i]++; + return true; + } + + static void PushSample(WLImpactTierSettings st, float damage) + { + int cap = Mathf.Clamp(st.damageAverageSamples, 2, s_samples.Length); + if (s_sampleCount == cap) + { + s_sampleSum -= s_samples[s_sampleHead]; + s_samples[s_sampleHead] = damage; + s_sampleSum += damage; + s_sampleHead = (s_sampleHead + 1) % cap; + } + else + { + int idx = (s_sampleHead + s_sampleCount) % cap; + s_samples[idx] = damage; + s_sampleSum += damage; + s_sampleCount++; + } + } + + // ───────────────────────────────────────── 티어별 값 공급(소비자용) + static float Get(float[] arr, int i, float fallback) + { + if (arr == null || i < 0 || i >= arr.Length) return fallback; + return arr[i]; + } + + /// 히트스톱 길이(초). 상한(hitStopMaxSeconds)으로 자른다. 0 = 그 티어는 히트스톱 없음. + public static float HitStopSeconds(ImpactTierKind tier) + { + var st = St; + if (st == null) return 0f; + float sec = Get(st.hitStopMs, (int)tier, 0f) * 0.001f; + return Mathf.Clamp(sec, 0f, Mathf.Max(0f, st.hitStopMaxSeconds)); + } + + /// 카메라 셰이크 진폭 배수. 0 = 셰이크 없음. + public static float ShakeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.shakeMultiplier, (int)tier, 1f); } + + /// 스케일 펀치 세기 배수(1 = WLHitFeelSettings.scalePunch 그대로). + public static float PunchMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.punchMultiplier, (int)tier, 1f); } + + /// 몹 점멸 색 배수(RGB 에만 곱한다). + public static float BlinkColorMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkColorMultiplier, (int)tier, 1f); } + + /// 몹 점멸 시간 배수. + public static float BlinkTimeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkTimeMultiplier, (int)tier, 1f); } + + /// 히트 이펙트 스케일 배수 — 【미적용 · 예약】 소비처가 원본(Assets/Script/**)이라 훅 0 제약에서 쓰지 못한다. + public static float HitEffectScale(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.hitEffectScale, (int)tier, 1f); } + + /// + /// 소비자(MobHitFlash)용 편의 API. 도장이 찍혀 있으면 그 티어, 아니면 Light. + /// 티어 시스템이 꺼져 있으면 배수 1(= 기존 동작)을 돌려준다. + /// + public static void GetBlinkModifiers(Actor victim, out float colorMul, out float timeMul) + { + colorMul = 1f; timeMul = 1f; + if (!Active || victim == null) return; + if (s_stampVictim != victim || s_stampFrame != Time.frameCount) return; + colorMul = BlinkColorMultiplier(s_stampTier); + timeMul = BlinkTimeMultiplier(s_stampTier); + } + + /// + /// 프로브용: 메모이즈 도장만 지운다. + /// 에디트 모드에는 프레임 진행이 없어 연속 Resolve 가 전부 '같은 프레임의 같은 피격자' 로 보이기 때문이다. + /// + public static void ClearStamp() { s_stampVictim = null; s_stampFrame = -1; } + + /// 프로브용: 카운터·평균·도장 초기화(구독은 유지). + public static void ResetDiagnostics() + { + LastTier = ImpactTierKind.Light; + for (int i = 0; i < 4; i++) { TierCount[i] = 0; s_lastFire[i] = -999f; s_fired[i] = 0; } + DowngradedCount = ResolvedCount = StampHitCount = 0; + LastDamage = LastAverage = 0f; + LastCritical = LastSkill = LastKill = LastBoss = false; + s_sampleHead = s_sampleCount = 0; s_sampleSum = 0f; + s_stampVictim = null; s_stampFrame = -1; + } + } +} diff --git a/Assets/WL/Combat/Reaction/ImpactTier.cs.meta b/Assets/WL/Combat/Reaction/ImpactTier.cs.meta new file mode 100644 index 000000000..5b6cb6bd7 --- /dev/null +++ b/Assets/WL/Combat/Reaction/ImpactTier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d751b0becf946bdbf780170487e215d +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 index 3a4d19e0c..abb3ae9b3 100644 --- a/Assets/WL/Combat/Reaction/MobHitFlash.cs +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -38,6 +38,8 @@ namespace WL.Combat.Reaction public bool blinkOn, tint; public float blinkOffAt, blinkReadyAt, tintHdr, tintRange; public int activeIndex = -1; + // WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1). + public float blinkColorMul = 1f, blinkTimeMul = 1f; } static readonly Dictionary s_states = new Dictionary(64); @@ -94,13 +96,17 @@ namespace WL.Combat.Reaction var s = GetState(victim, st); if (s == null) return; + // WL #811gh — 임팩트 티어별 점멸 색/시간 배수. 티어 시스템이 꺼져 있으면 (1, 1) = 기존 동작 그대로. + float colorMul, timeMul; + ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); float now = Time.unscaledTime; - if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장 + if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds * timeMul; 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; + s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul; + s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds * timeMul; Apply(s, st); Activate(s); OnCount++; @@ -236,8 +242,8 @@ namespace WL.Combat.Reaction 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 (res.hasColor) mpb.SetColor(res.colorId, ScaleRgb(st.blinkColor, s.blinkColorMul)); + if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, ScaleRgb(st.blinkEmission, s.blinkColorMul)); } if (s.tint) { @@ -249,6 +255,13 @@ namespace WL.Combat.Reaction } } + /// WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0). + static Color ScaleRgb(Color c, float m) + { + if (m == 1f) return c; + return new Color(c.r * m, c.g * m, c.b * m, c.a); + } + static void Activate(State s) { if (s.activeIndex >= 0) return; diff --git a/Assets/WL/Combat/Reaction/PcHitReaction.cs b/Assets/WL/Combat/Reaction/PcHitReaction.cs new file mode 100644 index 000000000..44ac44339 --- /dev/null +++ b/Assets/WL/Combat/Reaction/PcHitReaction.cs @@ -0,0 +1,356 @@ +// ───────────────────────────────────────────────────────────────────────────── +// PcHitReaction.cs — PC 피격 림라이트(MPB) + 넉백 (811g-① · 원본 훅 0 · 셰이더 교체 0 · 머티리얼 인스턴스 0) +// +// PD 지시 #813 · 발주서 WL-811gh §1-2 · 설계안 §C 811g 행 · 기준서 §B 요소 8-1 「HP + 연출 2종」 (2026-09-09) +// +// ■ 무엇을 하나 (CombatEvents.Damaged 구독 · MyActor.Get_Damage 훅은 811b 가 이미 넣어 둔 1줄) +// ① 림라이트 — PC 렌더러의 MaterialPropertyBlock 에 색·에미션을 0.15 s 얹었다가 비운다. +// MobHitFlash(몹 점멸)와 **완전히 같은 방식**이다: 렌더러당 MPB 1개 캐시 · 타격당 할당 0 · +// 머티리얼 인스턴스 0 · 드로우 +0. 새 셰이더/머티리얼 에셋을 만들지 않는다. +// ② 넉백 — 피격 방향 반대로 0.15 m 를 0.1 s 동안 민다(기본 = 보스·엘리트 피격만). +// · `Set_Warp` 를 쓰지 않는다(순간이동 금지 · 발주서 §1-2). +// · 도착 지점은 `NavMesh.SamplePosition` 으로 **NavMesh 위 점** 으로 접어서 쓴다 → NavMesh 밖으로 나갈 수 없다. +// · 에이전트가 살아 있으면 `NavMeshAgent.Move`(DashDriver·AttackStepDriver 선례 — transform 직접 대입은 +// 에이전트 내부 위치와 어긋나 다음 이동 명령에서 순간이동한다). SO `pcHitKnockbackUseAgentMove` 를 끄면 +// 발주서 문구대로 transform 보간만 쓴다. +// +// ■ 813tj HitVignette(UI 비네트)와의 관계 +// 비네트는 UI(캔버스)에서, 이쪽은 캐릭터(월드)에서 나온다. 둘이 합쳐 기준서 8-1 의 「HP + 연출 2종」이 된다. +// 이 파일은 UI 를 건드리지 않는다(UI 수정 0). +// +// ■ C8 롤백 +// WLImpactTierSettings 에셋이 없거나 enabled=0 또는 pcHitFlashEnabled/pcHitKnockbackEnabled=0 이면 +// 구독만 하고 아무 것도 하지 않는다 = 원본 동작 100 %. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using UnityEngine.AI; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class PcHitReaction + { + 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 flashOn; + public float flashOffAt, readyAt; + } + + static readonly Dictionary s_states = new Dictionary(4); + static readonly Dictionary s_byShader = new Dictionary(8); + static readonly List s_tmpRenderers = new List(16); + static FieldInfo s_rendererField; + static bool s_rendererFieldLooked; + + // ── 넉백 진행 상태(동시에 1개 = PC 는 1명) + static Actor s_knockActor; + static NavMeshAgent s_knockAgent; + static Vector3 s_knockDir; + static float s_knockRemain, s_knockSpeed, s_knockReadyAt; + static bool s_knockUseAgent; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int FlashOnCount, FlashOffCount, KnockbackCount, SkippedFilter, SkippedNoProperty, SkippedNoNavMesh, ThrottledCount, StatesCreated; + public static Vector3 LastKnockbackVector; + public static float LastKnockbackDistance; + public static bool LastKnockbackUsedAgent; + public static string LastInfo = ""; + public static bool FlashActive { get { foreach (var kv in s_states) if (kv.Value.flashOn) return true; return false; } } + + static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.Damaged.Add(OnDamaged); + Subscribed = true; + } + + // ───────────────────────────────────────── 이벤트 + static void OnDamaged(in DamagedEvent e) + { + var st = St; + if (st == null || !WLImpactTierSettings.Enabled) return; + var victim = e.victim; + if (victim == null || !IsMyPc(victim)) { SkippedFilter++; return; } + if (st.pcHitSkipWhenInvincible && e.invincible) { SkippedFilter++; return; } + if (st.pcHitSkipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; } + + if (st.pcHitFlashEnabled) Flash(st, victim); + if (st.pcHitKnockbackEnabled) Knockback(st, victim, e.attacker); + } + + /// 내 PC 인가. MyValue.MyPC 를 우선 보고(발주서 규격), 없으면 IsMainPC() 실측으로 판정한다. + static bool IsMyPc(Actor a) + { + var mine = MyValue.MyPC; + if (mine != null) return ReferenceEquals(a, mine); + return a.IsMainPC(); + } + + // ───────────────────────────────────────── ① 림라이트(MPB) + static void Flash(WLImpactTierSettings st, Actor victim) + { + var s = GetState(victim, st); + if (s == null) return; + float now = ImpactRunner.Now; + if (s.flashOn) { s.flashOffAt = now + Mathf.Max(0.01f, st.pcHitFlashSeconds); return; } // 연속 피격 = 연장 + if (now < s.readyAt) { ThrottledCount++; return; } + + ImpactRunner.Ensure(); + s.flashOn = true; + s.flashOffAt = now + Mathf.Max(0.01f, st.pcHitFlashSeconds); + Apply(s, st); + FlashOnCount++; + LastInfo = "flash on"; + if (Verbose) Debug.Log("[PcHitReaction] 림라이트 on " + victim.name + " renderers=" + s.renderers.Length); + } + + // ───────────────────────────────────────── ② 넉백 + static void Knockback(WLImpactTierSettings st, Actor victim, Actor attacker) + { + float now = ImpactRunner.Now; + if (now < s_knockReadyAt) { ThrottledCount++; return; } + + if (st.pcHitKnockbackBossEliteOnly) + { + bool bossElite = attacker != null && (attacker.IsSubRole(eSubRol.Boss) || attacker.IsSubRole(eSubRol.Elite)); + if (!bossElite) { SkippedFilter++; return; } + } + + float dist = Mathf.Max(0f, st.pcHitKnockbackDistance); + float dur = Mathf.Max(0.01f, st.pcHitKnockbackSeconds); + if (dist <= 0.0001f) return; + + Vector3 pos = victim.Get_position(); + Vector3 dir; + if (attacker != null) + { + dir = pos - attacker.Get_position(); + dir.y = 0f; + if (dir.sqrMagnitude < 0.000001f) dir = -victim.transform.forward; + else dir.Normalize(); + } + else dir = -victim.transform.forward; + dir.y = 0f; + if (dir.sqrMagnitude < 0.000001f) { SkippedFilter++; return; } + dir.Normalize(); + + // NavMesh 밖 이동 금지 — 목표점을 NavMesh 위 점으로 접는다(못 찾으면 넉백 자체를 포기). + Vector3 want = pos + dir * dist; + NavMeshHit navHit; + if (!NavMesh.SamplePosition(want, out navHit, Mathf.Max(0.05f, st.pcHitKnockbackNavSampleRadius), NavMesh.AllAreas)) + { SkippedNoNavMesh++; if (Verbose) Debug.Log("[PcHitReaction] 넉백 취소 — NavMesh 밖"); return; } + + Vector3 allowed = navHit.position - pos; + allowed.y = 0f; + float allowedDist = Mathf.Min(allowed.magnitude, dist); + if (allowedDist <= 0.0005f) { SkippedNoNavMesh++; return; } + if (allowed.sqrMagnitude > 0.000001f) dir = allowed.normalized; + + var agent = victim.Get_Agent(); + s_knockUseAgent = st.pcHitKnockbackUseAgentMove && agent != null && agent.enabled && agent.isOnNavMesh; + s_knockActor = victim; + s_knockAgent = s_knockUseAgent ? agent : null; + s_knockDir = dir; + s_knockRemain = dur; + s_knockSpeed = allowedDist / dur; + s_knockReadyAt = now + Mathf.Max(0f, st.pcHitKnockbackCooldownSeconds); + + LastKnockbackVector = dir * allowedDist; + LastKnockbackDistance = allowedDist; + LastKnockbackUsedAgent = s_knockUseAgent; + KnockbackCount++; + ImpactRunner.Ensure(); + LastInfo = "knockback " + allowedDist.ToString("F3"); + if (Verbose) Debug.Log("[PcHitReaction] 넉백 " + allowedDist.ToString("F3") + "m dir=" + dir + " agentMove=" + s_knockUseAgent); + } + + // ───────────────────────────────────────── 틱(ImpactRunner) + internal static void Tick(float now, float unscaledDelta) + { + // 림라이트 만료 + var st = St; + foreach (var kv in s_states) + { + var s = kv.Value; + if (!s.flashOn) continue; + if (st != null && WLImpactTierSettings.Enabled && now < s.flashOffAt) continue; + s.flashOn = false; + s.readyAt = now + (st != null ? Mathf.Max(0f, st.pcHitCooldownSeconds) : 0f); + if (st != null) Apply(s, st); + else Clear(s); + FlashOffCount++; + LastInfo = "flash off"; + } + + // 넉백 진행 + if (s_knockRemain > 0f) + { + if (s_knockActor == null || !s_knockActor.isActiveAndEnabled) { s_knockRemain = 0f; s_knockActor = null; s_knockAgent = null; return; } + float dt = Mathf.Min(unscaledDelta, s_knockRemain); + Vector3 step = s_knockDir * (s_knockSpeed * dt); + if (s_knockAgent != null && s_knockAgent.enabled && s_knockAgent.isOnNavMesh) s_knockAgent.Move(step); + else s_knockActor.transform.position += step; // 에이전트가 없을 때만: transform 보간 + s_knockRemain -= dt; + if (s_knockRemain <= 0f) { s_knockActor = null; s_knockAgent = null; } + } + } + + // ───────────────────────────────────────── 상태·MPB (MobHitFlash 와 같은 방식) + static State GetState(Actor actor, WLImpactTierSettings 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] }; + bool any = false; + 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); + if (s.resolved[i].hasColor || s.resolved[i].hasEmission) any = true; + } + if (!any) SkippedNoProperty++; + s_states.Add(actor, s); + StatesCreated++; + return s; + } + + /// Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다(MobHitFlash 와 같은 근거 · 원본 무수정). + 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) 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, WLImpactTierSettings st) + { + Resolved r; + if (shader == null) return default(Resolved); + if (s_byShader.TryGetValue(shader, out r)) return r; + r = default(Resolved); + var names = st.pcHitColorProperties; + 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.pcHitEmissionProperties; + 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 (Verbose) Debug.Log("[PcHitReaction] 셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission); + return r; + } + + static void Apply(State s, WLImpactTierSettings 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.flashOn) + { + var res = s.resolved[i]; + if (res.hasColor) mpb.SetColor(res.colorId, st.pcHitColor); + if (st.pcHitUseEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.pcHitEmission); + } + // 빈 블록을 써도 HasPropertyBlock 은 true 로 남는다 → 오버라이드가 없으면 null 로 떼어 SRP Batcher 로 복귀(MobHitFlash 실측 선례). + if (mpb.isEmpty) r.SetPropertyBlock(null); else r.SetPropertyBlock(mpb); + } + } + + static void Clear(State s) + { + for (int i = 0; i < s.renderers.Length; i++) + if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(null); } + } + + /// MPB 를 전부 비우고 넉백을 접는다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지. + public static void RestoreAll() + { + var st = St; + foreach (var kv in s_states) + { + var s = kv.Value; + if (!s.flashOn) continue; + s.flashOn = false; + if (st != null) Apply(s, st); else Clear(s); + } + s_knockRemain = 0f; s_knockActor = null; s_knockAgent = null; + } + + /// 씬 전환: 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 bool KnockbackActive { get { return s_knockRemain > 0f; } } + + /// + /// 프로브용 — 가짜 Damaged 를 **구독자 경로 그대로** 흘린다. + /// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독 자체가 없으므로 핸들러를 직접 부른다 + /// (HitVignette.RaiseFakeDamaged 선례와 같은 목적 · 게임 로직은 이 메서드를 쓰지 않는다). + /// + public static void ProbeDamaged(Actor victim, Actor attacker, double damage, bool invincible) + { + var e = new DamagedEvent + { + victim = victim, attacker = attacker, dinfo = null, damage = damage, + invincible = invincible, time = Time.unscaledTime, frame = Time.frameCount + }; + OnDamaged(in e); + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + FlashOnCount = FlashOffCount = KnockbackCount = SkippedFilter = SkippedNoProperty = SkippedNoNavMesh = ThrottledCount = StatesCreated = 0; + LastKnockbackVector = Vector3.zero; LastKnockbackDistance = 0f; LastKnockbackUsedAgent = false; + LastInfo = ""; s_knockReadyAt = -999f; + } + } +} diff --git a/Assets/WL/Combat/Reaction/PcHitReaction.cs.meta b/Assets/WL/Combat/Reaction/PcHitReaction.cs.meta new file mode 100644 index 000000000..e751681f3 --- /dev/null +++ b/Assets/WL/Combat/Reaction/PcHitReaction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1f8765aa0e9422ebded1bfc2e5c93f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs b/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs new file mode 100644 index 000000000..564f815d4 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs @@ -0,0 +1,269 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLImpactTierSettings.cs — 임팩트 티어(811h) · PC 피격 림라이트/넉백 · 대시 잔상(811g) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-811gh §1 · 설계안 §C 811g·811h 행 · 기준서 §B 요소 2(타격감)·8-1(피격 가시성) (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset +// → Resources.Load("WL/WLImpactTierSettings") 로 1회 로드 후 캐시. +// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 +// · WLHitFeel 은 **기존 2단(hitStopDuration/hitStopKillDuration · shakeKillMultiplier)** 그대로 돈다 +// · MobHitFlash 의 점멸 배수는 1(= 기존 색·시간) +// · PcHitReaction · DashAfterImage 는 구독만 하고 아무 것도 하지 않는다 +// = C8 롤백 1순위. 코드 무수정 롤백 = 이 에셋의 enabled 를 0 으로. +// +// ■ 티어 4단 (설계안 811h "평타 0 · 크리 1 · 킬/피니시 2" 를 보스 처치까지 4단으로 확장) +// Light 일반타 +// Heavy 치명(critical) · 스킬 투사체(ProjectileData.m_SkillListTableData != null) · 최근 평균 × N 이상 피해 +// Kill 이 타격으로 처치 +// BossKill 보스(eSubRol.Boss) 처치 · 옵션으로 엘리트(eSubRol.Elite) 포함 +// +// ■ 왜 값이 전부 여기 있나 — 코드 상수 0(조직 C45). 히트스톱 ms · 셰이크 배수 · 펀치 배수 · 점멸 배수 · +// 티어별 초당 상한 · PC 피격 · 잔상 수치가 전부 배열/필드다. PD 튜닝은 이 에셋만 고친다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Reaction +{ + /// 임팩트 티어 4단. 배열 인덱스와 같다(Light 0 · Heavy 1 · Kill 2 · BossKill 3). + public enum ImpactTierKind { Light = 0, Heavy = 1, Kill = 2, BossKill = 3 } + + /// 클래스(직업) ID → 잔상 색. classId = Actor.Get_ID() = ClassConfigTableData.n_ClassID 실측. + [System.Serializable] + public struct ImpactClassColor { public int classId; [ColorUsage(true, true)] public Color color; } + + /// 스킬 추가효과(eSkillExtraType) → 잔상 색. extraType = (int)eSkillExtraType. + [System.Serializable] + public struct ImpactElementColor { public int extraType; [ColorUsage(true, true)] public Color color; } + + [CreateAssetMenu(fileName = "WLImpactTierSettings", menuName = "WL/Impact Tier Settings", order = 324)] + public sealed class WLImpactTierSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLImpactTierSettings"; + + static WLImpactTierSettings s_cached; + static bool s_lookupDone; + + public static WLImpactTierSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 이 3기능만 끈다. 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("끄면 WLHitFeel 이 기존 2단으로 돌아가고 점멸 배수 1 · PC 피격 연출 0 · 대시 잔상 0 = 원본 동작(C8). 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("티어 판정·연출 결정을 Console 에 남긴다(검증용 · 문자열 결합이라 GC 발생 · 배포 시 0).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── ① 티어 판정 + [Header("① 티어 판정 (HitConfirmed → Light/Heavy/Kill/BossKill)")] + [Tooltip("끄면 티어가 항상 Light 로 고정된다(값 배열의 0번만 쓴다).")] + public bool tierEnabled = true; + + [Tooltip("메인 PC 가 때린 타격만 티어로 올린다. 끄면 펫·소환수 타격도 Heavy/Kill 로 올라간다.")] + public bool onlyByMainPC = true; + + [Tooltip("치명타(HitConfirmedEvent.critical = DamageInfo.Critical != eStat.None)를 Heavy 로 본다.")] + public bool heavyOnCritical = true; + + [Tooltip("스킬 투사체(ProjectileData.m_SkillListTableData != null)를 Heavy 로 본다.\n" + + "근접 평타도 0.02 s 박스 '투사체'라 projectile != null 만으로는 전부 Heavy 가 된다 → 스킬 테이블이 붙은 것만 본다(실측 CombatEvents.RaiseHitboxHit 의 isSkill 과 같은 식).")] + public bool heavyOnSkill = true; + + [Tooltip("최근 평균 피해 × 이 배수 이상이면 Heavy. 0 이하면 이 판정을 쓰지 않는다.")] + public float heavyDamageAverageMultiplier = 1.8f; + + [Tooltip("최근 평균을 내는 표본 수(링 버퍼 · 사전 할당 · GC 0). 이 수만큼 모이기 전에는 평균 판정을 쓰지 않는다.")] + [Range(2, 64)] public int damageAverageSamples = 16; + + [Tooltip("보스 처치를 BossKill 로 본다(eSubRol.Boss 실측 · SpawnedEvent.isBoss 와 같은 식).")] + public bool bossKillEnabled = true; + + [Tooltip("엘리트(eSubRol.Elite) 처치도 BossKill 로 본다.")] + public bool bossKillIncludesElite = true; + + // ─────────────────────────────────────────── ② 티어별 값 (배열 인덱스 = 티어) + [Header("② 티어별 값 — 인덱스 0 Light · 1 Heavy · 2 Kill · 3 BossKill")] + [Tooltip("히트스톱 길이(밀리초 · unscaled). 0 = 그 티어는 히트스톱 없음.\n" + + "발주 제안 0 / 40 / 70 / 120. 콤보 간격 0.42~0.47 s 라 Heavy 40 ms 는 리듬을 깨지 않는다.\n" + + "실제 적용은 TimeScaleArbiter(Pause > Cinematic > HitStop · 초당 hitStopPerSecondMax 스로틀) 경유.")] + public float[] hitStopMs = new float[] { 0f, 40f, 70f, 120f }; + + [Tooltip("히트스톱 안전 상한(초). 티어 시스템이 켜져 있으면 WLHitFeelSettings.hitStopMax(0.08) 대신 이 값으로 자른다.\n" + + "BossKill 120 ms 를 허용하려면 0.12 이상이어야 한다.")] + public float hitStopMaxSeconds = 0.13f; + + [Tooltip("카메라 셰이크 진폭 배수(WLHitFeelSettings.shakeAmplitude 에 곱한다). 0 = 그 티어는 셰이크 없음.\n" + + "발주 제안 0 / 1.0 / 1.4 / 2.2. 🔴 Light 0 은 '일반타에는 화면이 안 흔들린다' 는 뜻이다 —\n" + + "대비를 만드는 것이 티어 설계의 핵심이지만, 평타 피드백을 남기려면 여기만 0.4~0.6 으로 올리면 된다.")] + public float[] shakeMultiplier = new float[] { 0f, 1f, 1.4f, 2.2f }; + + [Tooltip("피격 몹 스케일 펀치 배수. 실제 펀치 = 1 + (WLHitFeelSettings.scalePunch - 1) × 이 값. 0 = 펀치 없음.\n" + + "Kill/BossKill 은 WLHitFeelSettings.skipPunchOnKill 이 켜져 있으면 애초에 펀치가 안 나간다.")] + public float[] punchMultiplier = new float[] { 1f, 1.4f, 1f, 1f }; + + [Tooltip("몹 점멸 색 배수(MobHitFlash 의 blinkColor·blinkEmission 의 RGB 에 곱한다 · 알파는 그대로). 1 = 기존 강도.")] + public float[] blinkColorMultiplier = new float[] { 1f, 1.35f, 1.7f, 2.2f }; + + [Tooltip("몹 점멸 시간 배수(MobHitFlash 의 blinkOnSeconds 에 곱한다). 1 = 기존 0.03 s.")] + public float[] blinkTimeMultiplier = new float[] { 1f, 1.3f, 1.8f, 2.4f }; + + [Tooltip("히트 이펙트 스케일 배수 — 【미적용 · 예약】 원본 히트 이펙트 스폰 지점이 Assets/Script/** 라\n" + + "'원본 훅 0' 제약에서 소비처가 없다. ImpactTier.HitEffectScale(tier) 로 읽을 수는 있다(후속 웨이브용).")] + public float[] hitEffectScale = new float[] { 1f, 1.15f, 1.3f, 1.6f }; + + [Tooltip("티어별 초당 발동 상한(회/초). 0 이하 = 제한 없음.\n" + + "히트스톱·셰이크는 구조상 동시 1개(TimeScaleArbiter 1층 · 셰이커 1개)라 '동시 상한' 은 초당 상한으로 실현한다.\n" + + "근접 스윙 1회가 군집 5~7 마리를 같은 프레임에 때리므로 Heavy 폭주를 여기서 막는다.")] + public float[] tierMaxPerSecond = new float[] { 0f, 4f, 3f, 0f }; + + // ─────────────────────────────────────────── ③ PC 피격 림라이트 (811g-①) + [Header("③ PC 피격 림라이트 (Damaged → MPB 에미션/색 · 셰이더 교체 0 · 머티리얼 인스턴스 0)")] + [Tooltip("PC 가 맞을 때 MPB 로 색·에미션을 올린다(MobHitFlash 와 같은 방식). 813tj HitVignette(UI)와 합쳐 기준서 8-1 「HP + 연출 2종」.")] + public bool pcHitFlashEnabled = true; + + [Tooltip("무적(invincible=true)일 때는 연출하지 않는다. Q2 무적 유지 중에는 이 값이 1 이면 연출이 아예 안 보인다 —\n" + + "무적 해제(D-6) 전에 화면에서 보려면 0 으로 두고 QA 한다.")] + public bool pcHitSkipWhenInvincible = true; + + [Tooltip("피해 0 은 연출하지 않는다.")] + public bool pcHitSkipWhenDamageZero = true; + + [Tooltip("림/에미션이 켜져 있는 시간(초 · unscaled). 발주 규정 0.15.")] + public float pcHitFlashSeconds = 0.15f; + + [Tooltip("연속 피격 시 다시 켜기까지의 최소 간격(초 · unscaled).")] + public float pcHitCooldownSeconds = 0.05f; + + [Tooltip("PC 피격 색(HDR · 알베도 색 프로퍼티). 붉은 기 = '내가 맞았다' 가시성.")] + [ColorUsage(true, true)] public Color pcHitColor = new Color(1.9f, 0.55f, 0.5f, 1f); + + [Tooltip("에미션도 함께 올린다(MK Toon 은 키워드 없이 가산 · 실측 _EmissionColor).")] + public bool pcHitUseEmission = true; + + [Tooltip("PC 피격 에미션 색(HDR) — 림라이트처럼 보이게 하는 주 성분.")] + [ColorUsage(true, true)] public Color pcHitEmission = new Color(1.2f, 0.15f, 0.1f, 1f); + + [Tooltip("알베도/베이스 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것 · Shader.FindPropertyIndex 실측).\n" + + "【실측 2026-09-09】 PC(Assets/Res_Addr/PC/Ai01.prefab)의 셰이더는 \"Toon/Toon\"(Unity Chan Toon Shader · UnityToon.shader:50) = _BaseColor 보유.\n" + + "몹(MK Toon)은 _AlbedoColor 라 둘 다 후보로 둔다 — 이 목록 하나로 PC·몹 양쪽을 덮는다.")] + public string[] pcHitColorProperties = new string[] { "_AlbedoColor", "_BaseColor" }; + + [Tooltip("에미션 색 프로퍼티 후보.\n" + + "【실측】 Toon/Toon 의 실제 에미션은 _Emissive_Color(HDR · CommonProperties.shaderblock:187)이고, _EmissionColor 도 프로퍼티로는 있으나\n" + + "셰이딩에 쓰이지 않는 잔재다 → _Emissive_Color 를 앞에 둔다.\n" + + "🔴 단, 이 셰이더의 에미션은 #pragma shader_feature _EMISSIVE_SIMPLE/_EMISSIVE_ANIMATION 로 키워드 게이팅된다.\n" + + " MPB 는 키워드를 켜지 못하므로, 에미션은 머티리얼이 이미 에미션을 쓰고 있을 때만 더해진다.\n" + + " PC 피격 연출의 **주 성분은 위 _BaseColor**(게이팅 없음)이다.")] + public string[] pcHitEmissionProperties = new string[] { "_Emissive_Color", "_EmissionColor" }; + + // ─────────────────────────────────────────── ④ PC 피격 넉백 (811g-①) + [Header("④ PC 피격 넉백 (피격 방향 반대로 짧게 · Set_Warp 금지 · NavMesh 밖 금지)")] + [Tooltip("PC 가 맞을 때 뒤로 살짝 밀린다.")] + public bool pcHitKnockbackEnabled = true; + + [Tooltip("보스·엘리트에게 맞았을 때만 넉백(기본). 끄면 모든 피격에 넉백 = 잡몹 군집에서 계속 밀린다.")] + public bool pcHitKnockbackBossEliteOnly = true; + + [Tooltip("넉백 거리(m). 발주 규정 0.15.")] + public float pcHitKnockbackDistance = 0.15f; + + [Tooltip("넉백 시간(초 · unscaled). 발주 규정 0.1.")] + public float pcHitKnockbackSeconds = 0.1f; + + [Tooltip("도착 지점이 NavMesh 위인지 검사할 반경(m). 검사에 실패하면 넉백을 아예 하지 않는다.")] + public float pcHitKnockbackNavSampleRadius = 0.5f; + + [Tooltip("에이전트가 살아 있으면 NavMeshAgent.Move 로 민다(DashDriver·AttackStepDriver 선례 — 직접 대입은 에이전트 내부 위치와 어긋나\n" + + "다음 이동 명령에서 순간이동한다). 끄면 발주서 문구대로 transform.position 보간만 쓴다. Set_Warp 는 어느 쪽도 쓰지 않는다.")] + public bool pcHitKnockbackUseAgentMove = true; + + [Tooltip("연속 넉백 최소 간격(초 · unscaled).")] + public float pcHitKnockbackCooldownSeconds = 0.25f; + + // ─────────────────────────────────────────── ⑤ 대시 잔상 (811g-②) + [Header("⑤ 대시 잔상 (Dash began → SkinnedMeshRenderer.BakeMesh 고스트)")] + [Tooltip("대시 시작에 잔상을 남긴다. 새 셰이더/머티리얼 에셋 0 — 런타임 머티리얼 1개를 만들어 모든 고스트가 공유한다.")] + public bool ghostEnabled = true; + + [Tooltip("대시 1회당 고스트 수. 발주 규정 3. BakeMesh 호출 수 = 이 값 × PC 의 SkinnedMeshRenderer 수.")] + [Range(0, 8)] public int ghostCount = 3; + + [Tooltip("고스트 사이 간격(초 · unscaled). 발주 규정 0.06.")] + public float ghostIntervalSeconds = 0.06f; + + [Tooltip("고스트 1개가 사라지는 데 걸리는 시간(초 · unscaled). 발주 규정 0.25.")] + public float ghostFadeSeconds = 0.25f; + + [Tooltip("고스트 풀 크기(오브젝트+메시 재사용). 발주 규정 6. 풀이 비면 그 고스트는 건너뛴다(큐잉 없음).")] + [Range(1, 24)] public int ghostPoolSize = 6; + + [Tooltip("PC 1명당 굽는 SkinnedMeshRenderer 최대 수(모바일 예산). 초과분은 무시한다.")] + [Range(1, 8)] public int ghostMaxRenderers = 3; + + [Tooltip("고스트 머티리얼 셰이더 후보(앞에서부터 Shader.Find 로 찾은 첫 것). 전부 없으면 잔상을 만들지 않는다.\n" + + "실측: URP 17.3 패키지에 \"Universal Render Pipeline/Unlit\" 이 있다(Shaders/Unlit.shader).")] + public string[] ghostShaderNames = new string[] { "Universal Render Pipeline/Unlit", "Unlit/Color", "Sprites/Default" }; + + [Tooltip("고스트 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것). URP Unlit = _BaseColor · Unlit/Color·Sprites/Default = _Color.")] + public string[] ghostColorProperties = new string[] { "_BaseColor", "_Color" }; + + [Tooltip("고스트 렌더 큐(3000 = Transparent). 반투명으로 그리기 위한 값.")] + public int ghostRenderQueue = 3000; + + [Tooltip("고스트 시작 알파(0~1). 여기서 0 까지 선형 감쇠한다.")] + [Range(0f, 1f)] public float ghostStartAlpha = 0.45f; + + [Tooltip("클래스·원소 색을 못 찾았을 때 쓰는 기본 색(발주 규정: 없으면 흰색).")] + [ColorUsage(true, true)] public Color ghostColorDefault = new Color(1f, 1f, 1f, 1f); + + [Tooltip("클래스(직업) ID → 잔상 색. classId = Actor.Get_ID()(PCActor.m_ID = ClassConfigTableData.n_ClassID 실측).\n" + + "비어 있으면 기본 색을 쓴다.")] + public ImpactClassColor[] ghostClassColors = new ImpactClassColor[0]; + + [Tooltip("마지막으로 시전한 스킬의 추가효과(eSkillExtraType) 색을 우선 쓴다(811i 와 같은 기준).")] + public bool ghostUseSkillElement = true; + + [Tooltip("그 스킬 색을 유효하게 보는 시간(초). 이 시간이 지나면 클래스 색/기본 색으로 돌아간다.")] + public float ghostElementMemorySeconds = 4f; + + [Tooltip("eSkillExtraType(int) → 잔상 색. 비어 있으면 원소 색을 쓰지 않는다.\n" + + "실측 enum 순서: 0 None · 1 Frost · 2 Cure · 3 Freezing · 4 Blood · 5 Poison · 6 Burn · 7 Stun · 8 Slow …")] + public ImpactElementColor[] ghostElementColors = new ImpactElementColor[0]; + + [Tooltip("화면(절두체) 밖이면 잔상을 만들지 않는다(EffectBudget.IsInView · viewMargin 은 코어 SO).")] + public bool ghostSkipOffscreen = true; + + [Tooltip("EffectBudget(AfterImage 슬롯 · 코어 SO afterImageMax) 을 경유한다. 끄면 상한 없이 만든다.")] + public bool ghostUseEffectBudget = true; + + [Tooltip("대시가 없는 클래스를 위해 공격 전진(AttackStepDriver) 시작에도 고스트 1개를 남긴다.\n" + + "AttackStepDriver.StepCount(정적 카운터) 변화를 폴링해서 감지한다 — 원본·기존 WL 파일 수정 0.")] + public bool ghostOnAttackStep = false; + + [Tooltip("공격 전진 고스트 수(ghostOnAttackStep 이 켜졌을 때).")] + [Range(0, 4)] public int ghostAttackStepCount = 1; + } +} diff --git a/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs.meta b/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs.meta new file mode 100644 index 000000000..e6e0d066f --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLImpactTierSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b35d970ee8004dbdb9d318edb23e570d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset new file mode 100644 index 000000000..27a405909 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset @@ -0,0 +1,105 @@ +%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: b35d970ee8004dbdb9d318edb23e570d, type: 3} + m_Name: WLImpactTierSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLImpactTierSettings + enabled: 1 + verboseLog: 0 + tierEnabled: 1 + onlyByMainPC: 1 + heavyOnCritical: 1 + heavyOnSkill: 1 + heavyDamageAverageMultiplier: 1.8 + damageAverageSamples: 16 + bossKillEnabled: 1 + bossKillIncludesElite: 1 + hitStopMs: + - 0 + - 40 + - 70 + - 120 + hitStopMaxSeconds: 0.13 + shakeMultiplier: + - 0 + - 1 + - 1.4 + - 2.2 + punchMultiplier: + - 1 + - 1.4 + - 1 + - 1 + blinkColorMultiplier: + - 1 + - 1.35 + - 1.7 + - 2.2 + blinkTimeMultiplier: + - 1 + - 1.3 + - 1.8 + - 2.4 + hitEffectScale: + - 1 + - 1.15 + - 1.3 + - 1.6 + tierMaxPerSecond: + - 0 + - 4 + - 3 + - 0 + pcHitFlashEnabled: 1 + pcHitSkipWhenInvincible: 1 + pcHitSkipWhenDamageZero: 1 + pcHitFlashSeconds: 0.15 + pcHitCooldownSeconds: 0.05 + pcHitColor: {r: 1.9, g: 0.55, b: 0.5, a: 1} + pcHitUseEmission: 1 + pcHitEmission: {r: 1.2, g: 0.15, b: 0.1, a: 1} + pcHitColorProperties: + - _AlbedoColor + - _BaseColor + pcHitEmissionProperties: + - _Emissive_Color + - _EmissionColor + pcHitKnockbackEnabled: 1 + pcHitKnockbackBossEliteOnly: 1 + pcHitKnockbackDistance: 0.15 + pcHitKnockbackSeconds: 0.1 + pcHitKnockbackNavSampleRadius: 0.5 + pcHitKnockbackUseAgentMove: 1 + pcHitKnockbackCooldownSeconds: 0.25 + ghostEnabled: 1 + ghostCount: 3 + ghostIntervalSeconds: 0.06 + ghostFadeSeconds: 0.25 + ghostPoolSize: 6 + ghostMaxRenderers: 3 + ghostShaderNames: + - Universal Render Pipeline/Unlit + - Unlit/Color + - Sprites/Default + ghostColorProperties: + - _BaseColor + - _Color + ghostRenderQueue: 3000 + ghostStartAlpha: 0.45 + ghostColorDefault: {r: 1, g: 1, b: 1, a: 1} + ghostClassColors: [] + ghostUseSkillElement: 1 + ghostElementMemorySeconds: 4 + ghostElementColors: [] + ghostSkipOffscreen: 1 + ghostUseEffectBudget: 1 + ghostOnAttackStep: 0 + ghostAttackStepCount: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset.meta new file mode 100644 index 000000000..06e3ff5d7 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLImpactTierSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d8495b3a79cc4f2eaf5afddf6f44c762 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Feel/WLHitFeel.cs b/Assets/WL/Feel/WLHitFeel.cs index 7220d2cbb..63db11b73 100644 --- a/Assets/WL/Feel/WLHitFeel.cs +++ b/Assets/WL/Feel/WLHitFeel.cs @@ -93,6 +93,13 @@ namespace WL.Feel public static float LastVictimBaseScaleX; // 그 몹의 펀치 직전 스케일(비율 계산 기준) public static bool LastWasKill; + // ── WL #811gh 임팩트 티어 진단(WL811gh_Probe 가 읽는다) + public static int LastTierIndex = -1; // 0 Light · 1 Heavy · 2 Kill · 3 BossKill · -1 = 티어 미사용(기존 2단) + public static float LastHitStopRequested; // 마지막으로 요청한 히트스톱 길이(초) + public static float LastShakeMultiplier; // 마지막으로 적용한 셰이크 진폭 배수 + public static float LastPunchMultiplier; // 마지막으로 적용한 펀치 세기 배수 + public static int TierSkippedShake; // 배수 0 으로 셰이크를 건너뛴 횟수 + public static void ResetDiagnostics() { HitCount = FiredCount = ThrottledCount = 0; @@ -182,34 +189,51 @@ namespace WL.Feel bool kill = dinfo.IsDeadByThisDamage; LastWasKill = kill; - if (st.logHits) - Debug.Log(string.Format("[WLHitFeel] FIRE t={0:F3} frame={1} victim={2} dmg={3} kill={4}", - LastHitTime, Time.frameCount, victim.name, dinfo.Damage, kill)); + // ── WL #811gh: 임팩트 티어 4단(Light/Heavy/Kill/BossKill). + // WLImpactTierSettings 에셋이 없거나 enabled=0 이면 tierActive=false → 아래는 전부 **기존 2단 경로 그대로**(C8). + bool tierActive = WL.Combat.Reaction.ImpactTier.Active; + var tier = tierActive ? WL.Combat.Reaction.ImpactTier.Resolve(victim, dinfo, kill) + : WL.Combat.Reaction.ImpactTierKind.Light; + LastTierIndex = tierActive ? (int)tier : -1; - // ── ① 히트스톱 + if (st.logHits) + Debug.Log(string.Format("[WLHitFeel] FIRE t={0:F3} frame={1} victim={2} dmg={3} kill={4} tier={5}", + LastHitTime, Time.frameCount, victim.name, dinfo.Damage, kill, tierActive ? tier.ToString() : "(off)")); + + // ── ① 히트스톱 (티어면 티어별 ms · 아니면 기존 2단) if (st.hitStopEnabled) { - float dur = Mathf.Min(kill ? st.hitStopKillDuration : st.hitStopDuration, st.hitStopMax); + float dur = tierActive + ? WL.Combat.Reaction.ImpactTier.HitStopSeconds(tier) + : Mathf.Min(kill ? st.hitStopKillDuration : st.hitStopDuration, st.hitStopMax); + LastHitStopRequested = dur; if (dur > 0.0001f) Runner.RunHitStop(st, dur); } - // ── ② 카메라 셰이크 + // ── ② 카메라 셰이크 (티어면 티어별 진폭 배수 · 배수 0 이면 아예 흔들지 않는다) if (st.cameraShakeEnabled) { - var shaker = GetShaker(true); - if (shaker != null) + float mul = tierActive + ? WL.Combat.Reaction.ImpactTier.ShakeMultiplier(tier) + : (kill ? Mathf.Max(1f, st.shakeKillMultiplier) : 1f); + LastShakeMultiplier = mul; + if (mul > 0.0001f) { - float amp = st.shakeAmplitude * (kill ? Mathf.Max(1f, st.shakeKillMultiplier) : 1f); - shaker.Shake(amp, st.shakeDuration, st.shakeFrequency, st.shakeAxisWeight); + var shaker = GetShaker(true); + if (shaker != null) + shaker.Shake(st.shakeAmplitude * mul, st.shakeDuration, st.shakeFrequency, st.shakeAxisWeight); } + else TierSkippedShake++; } - // ── ③ 피격 몹 스케일 펀치 + // ── ③ 피격 몹 스케일 펀치 (티어면 세기 배수) if (st.scalePunchEnabled && !(st.skipPunchOnKill && kill) && !(st.skipPunchOnBoss && victim.IsSubRole(eSubRol.Boss))) { + float pm = tierActive ? WL.Combat.Reaction.ImpactTier.PunchMultiplier(tier) : 1f; + LastPunchMultiplier = pm; LastVictimTf = victim.transform; LastVictimBaseScaleX = LastVictimTf.localScale.x; - Runner.RunScalePunch(st, victim); + Runner.RunScalePunch(st, victim, pm); } // ── ④ 화면 플래시 (기본 OFF) diff --git a/Assets/WL/Feel/WLHitFeelRunner.cs b/Assets/WL/Feel/WLHitFeelRunner.cs index 77c022fb2..b24adaf16 100644 --- a/Assets/WL/Feel/WLHitFeelRunner.cs +++ b/Assets/WL/Feel/WLHitFeelRunner.cs @@ -83,19 +83,26 @@ public class WLHitFeelRunner : MonoBehaviour } // ── ③ 피격 몹 스케일 펀치 ─────────────────────────────────────────────── - public void RunScalePunch(WLHitFeelSettings st, Actor victim) + /// 기존 호출 규약 유지(배수 1 = 설정값 그대로). + public void RunScalePunch(WLHitFeelSettings st, Actor victim) { RunScalePunch(st, victim, 1f); } + + /// WL #811gh — 티어별 펀치 세기 배수. 실제 배수 = 1 + (scalePunch - 1) × multiplier. + public void RunScalePunch(WLHitFeelSettings st, Actor victim, float multiplier) { if (victim == null) return; - StartCoroutine(CoScalePunch(st, victim)); + StartCoroutine(CoScalePunch(st, victim, multiplier)); } - IEnumerator CoScalePunch(WLHitFeelSettings st, Actor victim) + IEnumerator CoScalePunch(WLHitFeelSettings st, Actor victim, float multiplier) { if (victim == null) yield break; Transform tf = victim.transform; Vector3 baseScale = tf.localScale; - float punch = Mathf.Max(1f, st.scalePunch); + // 티어 배수는 '초과분' 에만 곱한다 — 배수 1 이면 기존 값과 완전히 같다(부동소수 오차 0). + float punch = multiplier == 1f + ? Mathf.Max(1f, st.scalePunch) + : Mathf.Max(1f, 1f + (Mathf.Max(1f, st.scalePunch) - 1f) * Mathf.Max(0f, multiplier)); if (punch <= 1.0001f || baseScale.x <= 0.0001f) yield break; // ProjectileBase.cs:305/308 은 transform.localScale.x > 2f 로 **보스를 판정**하고