[WL-811gh] 임팩트 티어 · PC 피격 림라이트/넉백 · 대시 잔상 (#813)

This commit is contained in:
깃 관리자 2026-09-09 08:43:09 +09:00
parent 3f87ed684a
commit adc96e91aa
16 changed files with 2283 additions and 20 deletions

View File

@ -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<string> s_log = new System.Collections.Generic.List<string>();
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<MobActor>();
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<MobActor>();
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<MobActor>();
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<PCActor>();
s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None;
string modelPath = FindSkinnedPrefab();
if (!string.IsNullOrEmpty(modelPath))
{
var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>(modelPath);
if (prefab != null)
{
s_modelGo = UnityEngine.Object.Instantiate(prefab, s_pcGo.transform);
s_modelGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
}
}
var smrs = s_pcGo.GetComponentsInChildren<UnityEngine.SkinnedMeshRenderer>(true);
var rends = s_pcGo.GetComponentsInChildren<UnityEngine.Renderer>(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<UnityEngine.GameObject>(p);
if (go == null) continue;
var smr = go.GetComponentInChildren<UnityEngine.SkinnedMeshRenderer>(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<UnityEngine.AI.NavMeshData>(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");
}
/// <summary>도장을 지우고 1건 판정한다(에디트 모드는 프레임이 멈춰 있어 메모이즈에 걸린다).</summary>
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<UnityEngine.AI.NavMeshAgent>(); // Actor.Awake 가 m_NavMeshAgent.speed 를 읽는다
var pc2 = go.AddComponent<PCActor>();
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) ? "있음" : "없음(!)"; }
}

View File

@ -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<SkinnedMeshRenderer> s_tmpSmr = new List<SkinnedMeshRenderer>(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<MeshFilter>();
var mr = go.AddComponent<MeshRenderer>();
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<SkinnedMeshRenderer>(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;
}
// ───────────────────────────────────────── 정리
/// <summary>진행 중인 고스트를 전부 끄고 예산을 돌려준다(러너 파괴 · 앱 종료 · 프로브 정리). 풀은 유지.</summary>
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);
}
}
/// <summary>씬 전환: 고스트를 끄고 렌더러 캐시(파괴된 Actor 참조)를 버린다. 풀 오브젝트는 DontDestroyOnLoad 라 유지.</summary>
public static void ClearAll()
{
RestoreAll();
s_smr = null; s_smrOwner = null; s_lastStepCount = -1;
}
/// <summary>풀·머티리얼·메시를 전부 파괴한다(프로브 정리 · 도메인 리로드 전).</summary>
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);
}
/// <summary>프로브용: 강제로 즉시 1개 남긴다(예약 없이).</summary>
public static void ForceSpawn(Actor actor)
{
var st = St;
if (st == null) return;
Spawn(st, actor != null ? actor : MyValue.MyPC);
}
/// <summary>프로브용: 예약 스폰 남은 수.</summary>
public static int PendingCount { get { return s_pendingLeft; } }
/// <summary>
/// 프로브용 — 가짜 Dash(began) 를 구독자 경로 그대로 흘린다.
/// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독이 없으므로 핸들러를 직접 부른다.
/// </summary>
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);
}
/// <summary>프로브용 — 예약된 스폰을 간격 대기 없이 전부 즉시 처리한다(에디트 모드에는 프레임이 없다).</summary>
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;
}
/// <summary>프로브용 — 지금 살아 있는 고스트를 강제로 만료시킨다(페이드 끝 상태 확인).</summary>
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);
}
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
SpawnCount = SkippedBudget = SkippedOffscreen = SkippedNoRenderer = SkippedNoShader = SkippedPoolFull = ReleasedCount = PoolCreated = BakeCount = StepSpawnCount = 0;
LastInfo = ""; s_lastStepCount = -1;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b984a9d7c124984b90b4ee40ecc807c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<ImpactRunner>();
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;
/// <summary>연출 타이밍의 기준 시각. Play = Time.unscaledTime · 프로브(ForceTick) = 시뮬레이션 시계.</summary>
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);
}
/// <summary>프로브(에디트 모드)용 — 러너 없이 같은 틱을 1회 돌리고 시뮬레이션 시계를 delta 만큼 민다.</summary>
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);
}
/// <summary>프로브용 — 시뮬레이션 시계를 끄고 실제 시간으로 되돌린다.</summary>
public static void ResetForceClock() { s_simActive = false; s_simNow = 0f; }
void OnDisable() { PcHitReaction.RestoreAll(); DashAfterImage.RestoreAll(); }
void OnApplicationQuit() { PcHitReaction.RestoreAll(); DashAfterImage.RestoreAll(); }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6130c1ec1faa415b9cc6e317d7a964d6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
/// <summary>최근 평균 피해(표본이 모자라면 -1).</summary>
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; } }
/// <summary>티어 시스템이 살아 있는가(에셋 · enabled · RuntimeDisabled · tierEnabled).</summary>
public static bool Active
{
get { var st = St; return st != null && st.enabled && !WLImpactTierSettings.RuntimeDisabled && st.tierEnabled; }
}
/// <summary>에셋은 살아 있는가(티어 판정은 꺼도 PC 피격·잔상은 돈다).</summary>
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;
}
// ───────────────────────────────────────── 소비자 진입점
/// <summary>
/// 이 타격의 티어. 구독자가 이미 확정했으면(같은 victim · 같은 프레임) 그 값을 그대로 돌려준다.
/// 코어 비활성(Dispatch 없음) 경로에서는 호출측 힌트로 그 자리에서 계산한다.
/// </summary>
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;
}
/// <summary>초당 상한 적용 후 최종 티어를 확정하고 카운터를 남긴다.</summary>
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];
}
/// <summary>히트스톱 길이(초). 상한(hitStopMaxSeconds)으로 자른다. 0 = 그 티어는 히트스톱 없음.</summary>
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));
}
/// <summary>카메라 셰이크 진폭 배수. 0 = 셰이크 없음.</summary>
public static float ShakeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.shakeMultiplier, (int)tier, 1f); }
/// <summary>스케일 펀치 세기 배수(1 = WLHitFeelSettings.scalePunch 그대로).</summary>
public static float PunchMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.punchMultiplier, (int)tier, 1f); }
/// <summary>몹 점멸 색 배수(RGB 에만 곱한다).</summary>
public static float BlinkColorMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkColorMultiplier, (int)tier, 1f); }
/// <summary>몹 점멸 시간 배수.</summary>
public static float BlinkTimeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkTimeMultiplier, (int)tier, 1f); }
/// <summary>히트 이펙트 스케일 배수 — 【미적용 · 예약】 소비처가 원본(Assets/Script/**)이라 훅 0 제약에서 쓰지 못한다.</summary>
public static float HitEffectScale(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.hitEffectScale, (int)tier, 1f); }
/// <summary>
/// 소비자(MobHitFlash)용 편의 API. 도장이 찍혀 있으면 그 티어, 아니면 Light.
/// 티어 시스템이 꺼져 있으면 배수 1(= 기존 동작)을 돌려준다.
/// </summary>
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);
}
/// <summary>
/// 프로브용: 메모이즈 도장만 지운다.
/// 에디트 모드에는 프레임 진행이 없어 연속 Resolve 가 전부 '같은 프레임의 같은 피격자' 로 보이기 때문이다.
/// </summary>
public static void ClearStamp() { s_stampVictim = null; s_stampFrame = -1; }
/// <summary>프로브용: 카운터·평균·도장 초기화(구독은 유지).</summary>
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2d751b0becf946bdbf780170487e215d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<Actor, State> s_states = new Dictionary<Actor, State>(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
}
}
/// <summary>WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0).</summary>
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;

View File

@ -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<Actor, State> s_states = new Dictionary<Actor, State>(4);
static readonly Dictionary<Shader, Resolved> s_byShader = new Dictionary<Shader, Resolved>(8);
static readonly List<Renderer> s_tmpRenderers = new List<Renderer>(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);
}
/// <summary>내 PC 인가. MyValue.MyPC 를 우선 보고(발주서 규격), 없으면 IsMainPC() 실측으로 판정한다.</summary>
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;
}
/// <summary>Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다(MobHitFlash 와 같은 근거 · 원본 무수정).</summary>
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<Renderer>(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); }
}
/// <summary>MPB 를 전부 비우고 넉백을 접는다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지.</summary>
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;
}
/// <summary>씬 전환: MPB 를 비우고 파괴된 Actor 참조를 버린다.</summary>
public static void ClearAll() { RestoreAll(); s_states.Clear(); }
/// <summary>프로브용: 그 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사).</summary>
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;
}
/// <summary>프로브용: 넉백이 진행 중인가.</summary>
public static bool KnockbackActive { get { return s_knockRemain > 0f; } }
/// <summary>
/// 프로브용 — 가짜 Damaged 를 **구독자 경로 그대로** 흘린다.
/// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독 자체가 없으므로 핸들러를 직접 부른다
/// (HitVignette.RaiseFakeDamaged 선례와 같은 목적 · 게임 로직은 이 메서드를 쓰지 않는다).
/// </summary>
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);
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
FlashOnCount = FlashOffCount = KnockbackCount = SkippedFilter = SkippedNoProperty = SkippedNoNavMesh = ThrottledCount = StatesCreated = 0;
LastKnockbackVector = Vector3.zero; LastKnockbackDistance = 0f; LastKnockbackUsedAgent = false;
LastInfo = ""; s_knockReadyAt = -999f;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f1f8765aa0e9422ebded1bfc2e5c93f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<WLImpactTierSettings>("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
{
/// <summary>임팩트 티어 4단. 배열 인덱스와 같다(Light 0 · Heavy 1 · Kill 2 · BossKill 3).</summary>
public enum ImpactTierKind { Light = 0, Heavy = 1, Kill = 2, BossKill = 3 }
/// <summary>클래스(직업) ID → 잔상 색. classId = Actor.Get_ID() = ClassConfigTableData.n_ClassID 실측.</summary>
[System.Serializable]
public struct ImpactClassColor { public int classId; [ColorUsage(true, true)] public Color color; }
/// <summary>스킬 추가효과(eSkillExtraType) → 잔상 색. extraType = (int)eSkillExtraType.</summary>
[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<WLImpactTierSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 이 3기능만 끈다. Play 종료 시 자동 false.</summary>
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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b35d970ee8004dbdb9d318edb23e570d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d8495b3a79cc4f2eaf5afddf6f44c762
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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)
{
float mul = tierActive
? WL.Combat.Reaction.ImpactTier.ShakeMultiplier(tier)
: (kill ? Mathf.Max(1f, st.shakeKillMultiplier) : 1f);
LastShakeMultiplier = mul;
if (mul > 0.0001f)
{
var shaker = GetShaker(true);
if (shaker != null)
{
float amp = st.shakeAmplitude * (kill ? Mathf.Max(1f, st.shakeKillMultiplier) : 1f);
shaker.Shake(amp, st.shakeDuration, st.shakeFrequency, st.shakeAxisWeight);
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)

View File

@ -83,19 +83,26 @@ public class WLHitFeelRunner : MonoBehaviour
}
// ── ③ 피격 몹 스케일 펀치 ───────────────────────────────────────────────
public void RunScalePunch(WLHitFeelSettings st, Actor victim)
/// <summary>기존 호출 규약 유지(배수 1 = 설정값 그대로).</summary>
public void RunScalePunch(WLHitFeelSettings st, Actor victim) { RunScalePunch(st, victim, 1f); }
/// <summary>WL #811gh — 티어별 펀치 세기 배수. 실제 배수 = 1 + (scalePunch - 1) × multiplier.</summary>
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 로 **보스를 판정**하고