From 1371982fecc6ebfe704db05680804ae7fcb749b8 Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 18 Sep 2026 00:02:13 +0900 Subject: [PATCH] =?UTF-8?q?[WL]=20=EC=A0=84=ED=88=AC=20=ED=83=80=EA=B2=A9?= =?UTF-8?q?=EA=B0=90=203=EA=B1=B4=20=E2=80=94=20=EB=8D=98=EC=A0=84=20?= =?UTF-8?q?=ED=92=80=20=EC=9E=90=EA=B8=80=EC=9E=90=EA=B8=80(=EC=A7=80?= =?UTF-8?q?=ED=98=95=20=EA=B7=B8=EB=8A=98=20=EC=B5=9C=EC=86=8C=20=EB=B0=9D?= =?UTF-8?q?=EA=B8=B0=20=3D=20=ED=92=80=EC=9E=8E)=20=C2=B7=20=EB=AA=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EA=B2=BD=EC=A7=81(=ED=94=BC=EA=B2=A9=20?= =?UTF-8?q?=EB=AA=A8=EC=85=98=200.25=20s=20=C2=B7=20=EA=B3=B5=EA=B2=A9=20?= =?UTF-8?q?=EB=81=8A=EA=B8=B0/=EB=A7=89=EA=B8=B0)=20=C2=B7=20=ED=9D=B0?= =?UTF-8?q?=EC=83=89=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8(?= =?UTF-8?q?=ED=94=BC=EA=B2=A9=20=EC=A0=90=EB=A9=B8=2020=20%=20=EB=8D=AE?= =?UTF-8?q?=EA=B8=B0=200.07=20s=20+=20=EB=81=8A=EA=B8=B4=20=EA=B3=B5?= =?UTF-8?q?=EA=B2=A9=EC=9D=98=20=EC=98=88=EA=B3=A0=20=ED=9D=B0=20=EC=84=AC?= =?UTF-8?q?=EA=B4=91=20=EC=A6=89=EC=8B=9C=20=EC=A2=85=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 던전 풀: 814t 대비 복제가 지형 _MinimumDarkness 만 0.12 로 낮춰 풀잎(0.20)이 그늘마다 밝은 점으로 도드라짐 → 지형을 풀잎 값으로(dungeonTerrainMatchGrassDarkness) - 경직: 피격 모션 재생(일반 몹은 공격 취소 · 엘리트 설정 · 보스/스킬/사망 제외) → 0.1 s 뒤 굳힘 → 경직 끝까지 공격 쿨타임 붙잡기 · 일반 0.25 / 엘리트 0.18 s · 끊긴 몹은 풀리면 애니 속도 1 - 피격 점멸: 켜짐 0.07(상한 0.09) · 쉼 0.2 s · 덧그리기 패스 _HitFlash 0.2(본체가 비치는 흰색 20 %) - 하얗게 남던 주원인(실측) = 공격 예고(Telegraph) 흰 섬광이 경직·넉백으로 끊긴 공격에 최대 2.5 s 남음 → CurAnim 이 공격 밖이면 즉시 종료 + 예정 히트 +0.3 s 안전장치 같은 세션 A/B(던전 1 · 3판씩): 끊긴 공격에 남은 흰색 47/80 측정 → 0/41 - 원본 0줄(Assets/Script · FarmingIsland 무수정) Co-Authored-By: Claude Opus 5 --- Assets/WL/Combat/Reaction/MobHitFlash.cs | 24 ++++- Assets/WL/Combat/Reaction/MobStagger.cs | 88 +++++++++++++++++-- .../WL/Combat/Reaction/WLHitBlinkSettings.cs | 16 +++- .../Resources/WL/WLHitBlinkSettings.asset | 9 +- .../Resources/WL/WLTelegraphSettings.asset | 2 + Assets/WL/Combat/Telegraph/Telegraph.cs | 16 +++- .../Combat/Telegraph/WLTelegraphSettings.cs | 8 ++ .../Resources/WL/WLHitFeelSettings.asset | 9 +- Assets/WL/Feel/WLHitFeelSettings.cs | 17 +++- .../Character/Shaders/WLCharBacklight.shader | 13 ++- .../WL/Look/Character/WLCharacterBacklight.cs | 26 ++++++ .../Resources/WL/WLIslandLookSettings.asset | 1 + Assets/WL/Look/Farm/WLDungeonGrassLook.cs | 29 +++++- Assets/WL/Look/Farm/WLIslandLookSettings.cs | 2 + 14 files changed, 235 insertions(+), 25 deletions(-) diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs b/Assets/WL/Combat/Reaction/MobHitFlash.cs index ffeb240ce..bb28fa7a3 100644 --- a/Assets/WL/Combat/Reaction/MobHitFlash.cs +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -58,6 +58,8 @@ namespace WL.Combat.Reaction static bool s_dissolveResolved, s_dissolveHasHdr, s_dissolveHasRange; static int s_edgeHdrId, s_edgeRangeId; static readonly List s_tmpRenderers = new List(16); + static readonly int kHitFlashId = Shader.PropertyToID("_HitFlash"), kHitFlashColorId = Shader.PropertyToID("_HitFlashColor"); + public static int OverlayBlinks, LegacyBlinks; // ── 진단(프로브가 읽는다) public static bool Subscribed; @@ -101,6 +103,9 @@ namespace WL.Combat.Reaction var s = GetState(victim, st); if (s == null) return; + // 2026-09-17 — 덮기 방식은 덧그리기 패스가 붙어 있어야 한다. 막 나온 몹(등급 검색 1.5 s 전)이면 지금 붙인다. + if (st.overlayMode && s.renderers.Length > 0 && !WL.Look.Character.WLCharacterBacklight.HasOverlay(s.renderers[0])) + WL.Look.Character.WLCharacterBacklight.ApplyTier2(victim.gameObject); // WL #811gh — 임팩트 티어별 점멸 색/시간 배수. 티어 시스템이 꺼져 있으면 (1, 1) = 기존 동작 그대로. float colorMul, timeMul; ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); @@ -255,15 +260,28 @@ namespace WL.Combat.Reaction } if (s.blinkOn) { - var res = s.resolved[i]; - 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 (st.overlayMode && WL.Look.Character.WLCharacterBacklight.HasOverlay(r)) + { + // 흰색을 overlayOpacity 만큼만 덮는다(본체가 비친다) — 임팩트 티어 배수는 1.5 배까지만 + mpb.SetFloat(kHitFlashId, Mathf.Clamp01(st.overlayOpacity * Mathf.Min(s.blinkColorMul, 1.5f))); + mpb.SetColor(kHitFlashColorId, st.overlayColor); + if (i == 0) OverlayBlinks++; + } + else + { + var res = s.resolved[i]; + 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 (i == 0) LegacyBlinks++; + } } if (s.tint) { if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr); if (s_dissolveHasRange && s.tintRange >= 0f) mpb.SetFloat(s_edgeRangeId, s.tintRange); } + // 2등급 본체 그늘 색은 이 블록에 같이 쓴다(블록 소유자 = 이 클래스 하나 · 따로 쓰면 맞을 때마다 지워진다) + WL.Look.Character.WLCharacterBacklight.WriteTier2Props(r, mpb); // 빈 블록을 써도 HasPropertyBlock 은 true 로 남는다(2026-09-09 Title Play 실측) → 오버라이드가 없으면 null 로 블록을 떼어 SRP Batcher 로 복귀 if (mpb.isEmpty) r.SetPropertyBlock(null); else r.SetPropertyBlock(mpb); } diff --git a/Assets/WL/Combat/Reaction/MobStagger.cs b/Assets/WL/Combat/Reaction/MobStagger.cs index c5c6827a8..f7a7a7fd4 100644 --- a/Assets/WL/Combat/Reaction/MobStagger.cs +++ b/Assets/WL/Combat/Reaction/MobStagger.cs @@ -7,7 +7,9 @@ // ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · Assets/Script 무수정) // HitConfirmed(몹 · 피해 > 0 · 이 타격으로 죽지 않음) // → 그 몹의 NavMeshAgent.speed 를 0 으로 잠그고 velocity 를 지우고 Animator.speed 를 0 으로 세운다. -// 등급별 시간(일반 0.18 · 엘리트 0.12 · 보스 0.08 s · WLHitFeelSettings)이 지나면 원래 값으로 되돌린다. +// 등급별 시간(일반 0.25 · 엘리트 0.18 · 보스 0.08 s · WLHitFeelSettings)이 지나면 원래 값으로 되돌린다. +// → 2026-09-17 PD 「히트모션 상태로 0.2~0.3초 멈추었다가 공격」: 피격 모션을 틀고(일반 몹은 공격도 끊음) 0.1 s 뒤 그 자세로 굳히며, +// 경직이 끝날 때까지 원본 공격 쿨타임을 붙잡아 새 공격을 막는다. 끊긴 공격의 예고는 Telegraph 가 바로 끝낸다(endOnAttackInterrupted). // → 다시 맞으면 시간이 갱신된다(연타 중에는 계속 붙잡혀 있다 = 히트 락). // Killed / Spawned(풀 재사용) → 즉시 원복하고 상태를 놓는다. // @@ -49,8 +51,15 @@ namespace WL.Combat.Reaction public float savedSpeed, savedAnimSpeed; public bool speedWritten, animWritten; public int activeIndex = -1; + // 2026-09-17 — 피격 모션 · 자세 굳히기 · 공격 막기 + public float freezeAt, lastHitAnimAt = -99f; + public bool hitAnimPlayed; } + static System.Reflection.FieldInfo s_coolField; + static bool s_coolFieldLooked; + static int s_hitStateHash; + static readonly Dictionary s_states = new Dictionary(64); static State[] s_active = new State[32]; static int s_activeCount; @@ -58,6 +67,7 @@ namespace WL.Combat.Reaction // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int StaggerCount, RefreshCount, ReleasedByCcCount, HoldCount, SkippedFilter, SkippedNoAgent, EndedCount; + public static int HitAnimPlays, AttackInterrupts, AttackBlocks, AttacksDuringStagger; public static float LastDuration; public static int ActiveCount { get { return s_activeCount; } } public static int StateCount { get { return s_states.Count; } } @@ -68,9 +78,17 @@ namespace WL.Combat.Reaction CombatEvents.HitConfirmed.Add(OnHitConfirmed); CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); + CombatEvents.AttackStarted.Add(OnAttackStarted); Subscribed = true; } + /// 진단 — 경직 중인 몹이 공격을 시작했는가(0 이어야 한다). + static void OnAttackStarted(in AttackStartedEvent e) + { + State s; + if (e.actor != null && !e.isMainPC && s_states.TryGetValue(e.actor, out s) && s.activeIndex >= 0) AttacksDuringStagger++; + } + static WLHitFeelSettings St { get { return WL.Feel.WLHitFeel.Settings; } } /// 설정 에셋이 없거나 전체 스위치·경직 스위치가 꺼져 있으면 이 시스템은 통째로 비활성. @@ -104,10 +122,45 @@ namespace WL.Combat.Reaction LastDuration = dur; WLReactionRunner.Ensure(); - Freeze(s); + PlayHitMotion(s, st, victim, now); + Freeze(s, now); Activate(s); } + /// + /// 🔴 2026-09-17 PD 「히트모션 상태로 멈추었다가 공격을 시도」 — 피격 모션을 튼다. 원본 `MobActor.Play_Hit` 는 공격 중이면 그냥 돌아가므로 + /// (공격이 끊기지 않는다) 일반 몹은 공개 API(`Set_CurAnim`·`AnimationPlay`)로 직접 피격 상태로 넘긴다 = 공격 취소. 엘리트는 설정, 보스·스킬·사망은 끊지 않는다. + /// + static void PlayHitMotion(State s, WLHitFeelSettings st, Actor victim, float now) + { + s.hitAnimPlayed = false; + var an = s.anim; + if (an == null) { s.freezeAt = now; return; } + var cur = victim.Get_CurAnim(); + bool attacking = cur >= eAnim.Attack && cur <= eAnim.Attack4; + bool boss = victim.IsSubRole(eSubRol.Boss), elite = victim.IsSubRole(eSubRol.Elite); + bool can = cur != eAnim.Die && cur != eAnim.Skill && !boss; + if (can && attacking) can = elite ? st.mobStaggerInterruptElite : st.mobStaggerInterruptAttack; + if (!can) { s.freezeAt = now; return; } // 끊지 않는 대상 = 종전처럼 그 자세로 바로 멈춘다(히트스톱) + + if (s_hitStateHash == 0) s_hitStateHash = Animator.StringToHash(MyValue.Get_AnimName(eAnim.Hit)); + if (!an.HasState(0, s_hitStateHash)) { s.freezeAt = now; return; } // 피격 상태가 없는 애니메이터 + + if (now - s.lastHitAnimAt >= st.mobStaggerReplayInterval) + { + // 피격 모션은 원본 Play_Hit 처럼 속도 1 로 튼다 → 경직이 풀리면 1 로 돌아간다(끊긴 공격·예고가 늦춰 둔 속도를 이어받지 않는다) + s.savedAnimSpeed = 1f; + an.speed = 1f; + victim.Set_CurAnim(eAnim.Hit); + victim.AnimationPlay(MyValue.Get_AnimName(eAnim.Hit)); + s.lastHitAnimAt = now; + HitAnimPlays++; + if (attacking) AttackInterrupts++; + s.freezeAt = st.mobStaggerFreezeDelay >= 0f ? now + st.mobStaggerFreezeDelay : float.MaxValue; + } + s.hitAnimPlayed = true; + } + static void OnKilled(in KilledEvent e) { Release(e.victim); } static void OnSpawned(in SpawnedEvent e) { Release(e.actor); } @@ -138,8 +191,8 @@ namespace WL.Combat.Reaction return s; } - /// speed 0 · velocity 0 · Animator.speed 0. 원래 값은 처음 얼릴 때 1회만 기억한다. - static void Freeze(State s) + /// speed 0 · velocity 0 · (freezeAt 이후) Animator.speed 0 · 공격 쿨타임 붙잡기. 원래 값은 처음 얼릴 때 1회만 기억한다. + static void Freeze(State s, float now) { var a = s.agent; if (a != null) @@ -150,11 +203,29 @@ namespace WL.Combat.Reaction if (a.enabled && a.isOnNavMesh) a.velocity = Vector3.zero; } var an = s.anim; - if (an != null) + if (an != null && now >= s.freezeAt) { - if (!s.animWritten) { s.savedAnimSpeed = an.speed; s.animWritten = true; } + if (!s.animWritten) { if (!s.hitAnimPlayed) s.savedAnimSpeed = an.speed; s.animWritten = true; } an.speed = 0f; } + BlockAttack(s, now); + } + + /// 경직이 끝날 때까지 새 공격을 못 시작하게 원본의 공격 쿨타임(`Actor.m_attackCoolTime` · protected double)을 붙잡는다(리플렉션 읽기/쓰기 · 원본 코드 0줄). + static void BlockAttack(State s, float now) + { + var st = St; + if (st == null || !st.mobStaggerBlockAttack || s.actor == null) return; + if (!s_coolFieldLooked) + { + s_coolFieldLooked = true; + s_coolField = typeof(Actor).GetField("m_attackCoolTime", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + } + if (s_coolField == null) return; + double remain = (s.until - now) + 0.05; + if (remain <= 0.0) return; + double cur = (double)s_coolField.GetValue(s.actor); + if (cur < remain) { s_coolField.SetValue(s.actor, remain); AttackBlocks++; } } /// 우리가 쓴 0 이 그대로 남아 있을 때만 원래 값으로 되돌린다(버프·사망·리스폰이 바꿨으면 손대지 않는다). @@ -169,7 +240,7 @@ namespace WL.Combat.Reaction if (s.animWritten) { var an = s.anim; - if (an != null && an.speed == 0f) an.speed = s.savedAnimSpeed; + if (an != null && an.speed == 0f) an.speed = s.savedAnimSpeed > 0f ? s.savedAnimSpeed : 1f; // 0 을 기억했다면(다른 정지 중에 맞음) 1 로 s.animWritten = false; } } @@ -225,7 +296,7 @@ namespace WL.Combat.Reaction Restore(s); Deactivate(s); EndedCount++; continue; } - Freeze(s); // AI 상태 전이가 speed 를 되돌려도 매 틱 다시 0 으로 잠근다 + Freeze(s, now); // AI 상태 전이가 speed 를 되돌려도 매 틱 다시 0 으로 잠근다 } } @@ -255,6 +326,7 @@ namespace WL.Combat.Reaction public static void ResetDiagnostics() { StaggerCount = RefreshCount = ReleasedByCcCount = HoldCount = SkippedFilter = SkippedNoAgent = EndedCount = 0; + HitAnimPlays = AttackInterrupts = AttackBlocks = AttacksDuringStagger = 0; LastDuration = 0f; } } diff --git a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs index d01dee3f2..2a4834ce2 100644 --- a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs +++ b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs @@ -83,15 +83,25 @@ namespace WL.Combat.Reaction [ColorUsage(true, true)] public Color blinkEmission = new Color(0.9f, 0.9f, 0.9f, 1f); [Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")] - public float blinkOnSeconds = 0.03f; + public float blinkOnSeconds = 0.07f; [Tooltip("한 번 켜진 점멸이 이어질 수 있는 **총 상한**(초 · unscaled · 0 이하면 상한 없음).\n" + "PD #816zb — 몹이 계속 하얗게 보이던 원인은 ① 임팩트 티어 시간 배수(최대 2.4 → 0.072 s)와\n" + "② 점멸 중 추가 타격마다 blinkOffAt 을 다시 미루는 연장 경로였다. 연장까지 포함해 첫 점멸 시작 시각 + 이 값에서 끊는다. 0.06 = 발주 규정값.")] - public float blinkMaxSeconds = 0.06f; + public float blinkMaxSeconds = 0.09f; [Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")] - public float blinkOffSeconds = 0.03f; + public float blinkOffSeconds = 0.2f; + + // ── 2026-09-17 PD 「히트된 몬스터가 하얗게 하이라이트된 상태로 유지되는 경우가 많다 → 피격 시점에 짧게(0.1초 미만) · 완전히 흰색으로 덮지 말고 투명도 20 % 정도」 + // 원인(값 계산): 켜짐 최대 0.06 s / 쉼 0.03 s 라 연속 피격 중엔 최대 66 % 시간이 흰색 + 알베도 HDR 3배 = 완전 흰색. + // 🔴 하얗게 **오래** 남던 주원인은 이 점멸이 아니라 공격 예고의 흰 섬광이 끊긴 공격에 남은 것(실측 · `WLTelegraphSettings.endOnAttackInterrupted`). + // → 켜짐 0.07(상한 0.09) · 쉼 0.2 s(= 연타 중에도 또렷이 끊어지는 점멸) · 그리기는 캐릭터 덧그리기 패스(`WL/CharBacklight`)의 `_HitFlash` 로 **흰색을 20 % 만 덮는다**. + [Tooltip("true = 덧그리기 패스가 붙은 렌더러는 흰색을 overlayOpacity 만큼만 덮는다(본체 색이 비친다). false 또는 패스가 없으면 예전 방식(알베도 HDR = 완전 흰색).")] + public bool overlayMode = true; + [Tooltip("덮는 정도(0~1). PD 2026-09-17 = 0.2.")] + [Range(0f, 1f)] public float overlayOpacity = 0.2f; + public Color overlayColor = Color.white; [Tooltip("알베도/베이스 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것을 쓴다 · Shader.FindPropertyIndex 실측). 없으면 그 렌더러는 건너뜀.")] public string[] colorProperties = new string[] { "_AlbedoColor", "_BaseColor" }; diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset index 7cd2c5940..d2002854e 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset @@ -20,9 +20,12 @@ MonoBehaviour: blinkColor: {r: 3, g: 3, b: 3, a: 1} useEmission: 1 blinkEmission: {r: 0.9, g: 0.9, b: 0.9, a: 1} - blinkOnSeconds: 0.03 - blinkMaxSeconds: 0.06 - blinkOffSeconds: 0.03 + blinkOnSeconds: 0.07 + blinkMaxSeconds: 0.09 + blinkOffSeconds: 0.2 + overlayMode: 1 + overlayOpacity: 0.2 + overlayColor: {r: 1, g: 1, b: 1, a: 1} colorProperties: - _AlbedoColor - _BaseColor diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset index 230009cfa..0ef2c166d 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset @@ -27,6 +27,8 @@ MonoBehaviour: defaultHitSeconds: 0.16 correctFromLiveClip: 1 maxTelegraphSeconds: 2.5 + endOnAttackInterrupted: 1 + lateHitGraceSeconds: 0.3 decalEnabled: 0 maxConcurrent: 6 poolWarmCount: 8 diff --git a/Assets/WL/Combat/Telegraph/Telegraph.cs b/Assets/WL/Combat/Telegraph/Telegraph.cs index b2b29a099..e443c16a5 100644 --- a/Assets/WL/Combat/Telegraph/Telegraph.cs +++ b/Assets/WL/Combat/Telegraph/Telegraph.cs @@ -78,7 +78,7 @@ namespace WL.Combat.Telegraph public static bool Subscribed; public static int StartCount, EndCount, SkippedBoss, SkippedNotMob, SkippedDisabled, SkippedNoTable, TimeoutCount, SpeedApplied, SpeedClamped, SpeedRestored, TintApplied, TintRestoredElite, PulseApplied, PulseRestored, - FlashCount, SfxPlayed, SfxThrottled, SfxNoManager, DecalOn, DecalSkipped, HitTimeFallback, HitTimeCorrected; + FlashCount, SfxPlayed, SfxThrottled, SfxNoManager, DecalOn, DecalSkipped, HitTimeFallback, HitTimeCorrected, InterruptedCount, LateCount; public static float LastPlannedSeconds = -1f, LastHitSeconds = -1f, LastOrigSpeed = -1f, LastAppliedSpeed = -1f, LastTargetSeconds = -1f; public static bool LastWasRanged, LastWasClamped; public static string LastInfo = ""; @@ -265,9 +265,21 @@ namespace WL.Combat.Telegraph if (elapsed > Mathf.Max(0.1f, st.maxTelegraphSeconds) || !s.actor.gameObject.activeInHierarchy) { TimeoutCount++; EndInternal(s, false, true); continue; } + // 2026-09-17 — 공격이 끊겼으면(원본 CurAnim 이 공격 밖) 기다리지 않고 바로 끝낸다. 종전엔 흰 섬광이 위 시간(2.5 s)까지 남았다. + if (st.endOnAttackInterrupted) + { + var cur = s.actor.Get_CurAnim(); + if (cur < eAnim.Attack || cur > eAnim.Attack4) { InterruptedCount++; EndInternal(s, false, true); continue; } + } + if (!s.corrected) CorrectFromLiveClip(s, st); float planned = Mathf.Max(0.0001f, s.plannedSeconds); + + // 2026-09-17 — 예정 히트 시각이 지나고도 히트가 안 오면(CurAnim 이 늦게 바뀌는 경우) 흰 섬광을 오래 두지 않는다 + if (st.lateHitGraceSeconds > 0f && elapsed > planned + st.lateHitGraceSeconds) + { LateCount++; EndInternal(s, false, true); continue; } + float t = Mathf.Clamp01(elapsed / planned); if (s.decalIdx >= 0) { TelegraphDecal.Follow(s.decalIdx, s.actor); TelegraphDecal.SetFill(s.decalIdx, t); } @@ -594,7 +606,7 @@ namespace WL.Combat.Telegraph { StartCount = EndCount = SkippedBoss = SkippedNotMob = SkippedDisabled = SkippedNoTable = TimeoutCount = 0; SpeedApplied = SpeedClamped = SpeedRestored = TintApplied = TintRestoredElite = PulseApplied = PulseRestored = 0; - FlashCount = SfxPlayed = SfxThrottled = SfxNoManager = DecalOn = DecalSkipped = HitTimeFallback = HitTimeCorrected = 0; + FlashCount = SfxPlayed = SfxThrottled = SfxNoManager = DecalOn = DecalSkipped = HitTimeFallback = HitTimeCorrected = InterruptedCount = LateCount = 0; LastPlannedSeconds = LastHitSeconds = LastOrigSpeed = LastAppliedSpeed = LastTargetSeconds = -1f; LastWasRanged = LastWasClamped = false; LastInfo = ""; diff --git a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs index 1bcc4c8d6..442b58890 100644 --- a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs +++ b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs @@ -108,6 +108,14 @@ namespace WL.Combat.Telegraph [Tooltip("HitboxSpawned 가 이 시간 안에 안 오면 강제로 원복한다(피격·사망으로 공격이 끊긴 경우 · 초).")] public float maxTelegraphSeconds = 2.5f; + // 2026-09-17 PD 「히트 된 몬스터가 하얗게 하이라이트 된 상태로 유지되는 경우가 많다」 실측 원인 = 끊긴 공격의 예고가 위 시간까지 남아 + // 흰 섬광(flashAlbedo 2.2 · flashEmission 1.6)이 최대 2.5 s 유지됐다(경직이 공격을 끊으면서 더 잦아짐). + [Tooltip("true = 몹이 더 이상 공격 모션이 아니면(피격 모션·넉백·CC·타깃 상실 = 원본 CurAnim 이 Attack~Attack4 밖) 그 즉시 예고를 끝낸다(색·섬광·속도·크기 원복).\nfalse = 종전처럼 maxTelegraphSeconds 까지 기다린다.")] + public bool endOnAttackInterrupted = true; + + [Tooltip("예정 히트 시각이 이만큼(초) 지나도 히트가 안 오면 예고를 끝낸다(원본 CurAnim 이 늦게 바뀌는 경우의 안전장치 · 흰 섬광이 남는 시간의 상한).\n0 이하 = 끄기(maxTelegraphSeconds 까지 기다림).")] + public float lateHitGraceSeconds = 0.3f; + // ─────────────────────────────────────────── ① 지면 데칼 [Header("① 지면 데칼 (투사체 BoxCollider 를 읽어 같은 크기 · 런타임 쿼드 2장 · 풀링)")] [Tooltip("끄면 판을 그리지 않는다.")] diff --git a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset index 0806f2dc7..7bf0b331f 100644 --- a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset +++ b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset @@ -38,7 +38,12 @@ MonoBehaviour: flashDuration: 0.06 hapticEnabled: 0 mobStaggerEnabled: 1 - mobStaggerSeconds: 0.18 - mobStaggerSecondsElite: 0.12 + mobStaggerSeconds: 0.25 + mobStaggerSecondsElite: 0.18 mobStaggerSecondsBoss: 0.08 + mobStaggerInterruptAttack: 1 + mobStaggerInterruptElite: 0 + mobStaggerBlockAttack: 1 + mobStaggerFreezeDelay: 0.1 + mobStaggerReplayInterval: 0.2 logHits: 0 diff --git a/Assets/WL/Feel/WLHitFeelSettings.cs b/Assets/WL/Feel/WLHitFeelSettings.cs index 7634e5061..bf40e772d 100644 --- a/Assets/WL/Feel/WLHitFeelSettings.cs +++ b/Assets/WL/Feel/WLHitFeelSettings.cs @@ -151,14 +151,27 @@ public class WLHitFeelSettings : ScriptableObject [Tooltip("일반 몹 경직 길이(초 · unscaled). 콤보 간격 0.42~0.47 s 의 절반 이하로 둔다 — 0.18 = 발주 규정값.\n" + "넉백 중에 맞으면 넉백이 끝난 시점부터 이 길이를 1회 다시 준다(밀리다 말고 곧장 걷지 않게).")] - public float mobStaggerSeconds = 0.18f; + public float mobStaggerSeconds = 0.25f; [Tooltip("엘리트 경직 길이(초). 일반보다 짧게 — 압박감을 유지한다.")] - public float mobStaggerSecondsElite = 0.12f; + public float mobStaggerSecondsElite = 0.18f; [Tooltip("보스 경직 길이(초). 가장 짧게 — 보스 패턴이 끊기면 안 된다.")] public float mobStaggerSecondsBoss = 0.08f; + // ── 2026-09-17 PD 「몬스터가 피격 후 경직되지 않아 타격감이 부족 → 밀린 상태에서 경직 시간을 주어 히트모션 상태로 0.2~0.3초 멈추었다가 공격을 시도」 + // 종전 = 이동 속도 0 + 애니메이터 정지(0.18 s)뿐이라 ① 공격 중인 몹은 공격 모션 그대로 얼었다 풀려 그대로 때렸고 ② 새 공격도 경직 중에 시작됐다. + [Tooltip("true = 공격 중인 **일반** 몹도 피격 모션으로 끊는다(공격 취소 → 경직 뒤 다시 시도). 원본 Play_Hit 는 공격 중이면 피격 모션을 안 튼다.")] + public bool mobStaggerInterruptAttack = true; + [Tooltip("true = 엘리트도 공격을 끊는다. 기본 false(압박감 유지 · 엘리트는 모션만 잠깐 멈춘다). 보스는 항상 끊지 않는다.")] + public bool mobStaggerInterruptElite = false; + [Tooltip("true = 경직 중에는 새 공격을 시작하지 못하게 공격 쿨타임을 경직이 끝날 때까지 붙잡아 둔다(원본 필드 읽기/쓰기만 · 원본 코드 0줄).")] + public bool mobStaggerBlockAttack = true; + [Tooltip("피격 모션을 이만큼(초) 재생한 뒤 그 자세로 멈춘다(움찔한 자세로 굳는다). 음수 = 멈추지 않고 모션을 끝까지 재생.")] + public float mobStaggerFreezeDelay = 0.1f; + [Tooltip("연타 중 피격 모션을 다시 트는 최소 간격(초). 너무 짧으면 첫 프레임만 반복돼 떨려 보인다.")] + public float mobStaggerReplayInterval = 0.2f; + // ─────────────────────────────────────────── 진단 [Header("진단 (검증용 · 배포 시 false)")] diff --git a/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader index 3bd423077..76d0500b0 100644 --- a/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader +++ b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader @@ -54,6 +54,10 @@ Shader "WL/CharBacklight" _FaceStrength ("Face Light Strength (0 = 끔)", Float) = 0.3 _FaceWrap ("Face Light Wrap (0 = 또렷 · 1 = 둥글게 감싸는 빛)", Range(0, 1)) = 0.5 _FillStrength ("Body Fill Strength (위치 없는 채움빛 · 2등급용)", Float) = 0 + + [Header(Hit flash overlay)] + _HitFlash ("Hit Flash Opacity (렌더러별 프로퍼티 블록이 쓴다 · 0 = 없음)", Range(0, 1)) = 0 + _HitFlashColor ("Hit Flash Color", Color) = (1, 1, 1, 1) } SubShader @@ -107,6 +111,8 @@ Shader "WL/CharBacklight" float _FaceStrength; float _FaceWrap; float _FillStrength; + float _HitFlash; + float4 _HitFlashColor; CBUFFER_END // xyz = 표면에서 가상 역광 쪽을 가리키는 월드 방향(정규화) · w = 1 이면 유효(0 = 아직 안 들어옴 → 위쪽) @@ -206,7 +212,12 @@ Shader "WL/CharBacklight" } float3 col = _RimColor.rgb * (rim * _RimStrength) + _WrapColor.rgb * wrap + _SssColor.rgb * sss + _FaceColor.rgb * face; - return half4(col, rim * saturate(_RimOpacity)); + float a = rim * saturate(_RimOpacity); + // ⑤ 피격 하이라이트 — 본체 위에 흰색을 _HitFlash 만큼만 덮는다(완전히 하얗게 되지 않는다 · 2026-09-17 PD 「투명도 20 % 정도」) + float hf = saturate(_HitFlash); + col = col * (1.0 - hf) + _HitFlashColor.rgb * hf; + a = 1.0 - (1.0 - a) * (1.0 - hf); + return half4(col, a); } ENDHLSL } diff --git a/Assets/WL/Look/Character/WLCharacterBacklight.cs b/Assets/WL/Look/Character/WLCharacterBacklight.cs index 18218c066..8bc91d5af 100644 --- a/Assets/WL/Look/Character/WLCharacterBacklight.cs +++ b/Assets/WL/Look/Character/WLCharacterBacklight.cs @@ -108,6 +108,7 @@ namespace WL.Look.Character r.sharedMaterials = s_tmp.ToArray(); BrightenBase(null, r, s_tmp.Count > 0 ? s_tmp[0] : null, false); // 우리가 붙였던 렌더러만 본체 값도 되돌린다 RestoreMkTint(r, s_tmp.Count > 0 ? s_tmp[0] : null); + s_overlayRends.Remove(r); } } LastLog = "역광 패스 제거 @" + root.name; @@ -164,6 +165,29 @@ namespace WL.Look.Character // ───────────────────────────────────────────────────────────────── public static int Tier2Roots, Tier2Renderers, Tier3Renderers, Tier3Materials; static readonly int kGoochDark = Shader.PropertyToID("_GoochDarkColor"); + static readonly HashSet s_overlayRends = new HashSet(); + + /// 이 렌더러에 우리 덧그리기 패스가 붙어 있는가(피격 하이라이트가 덮기 방식으로 그릴 수 있는가). + public static bool HasOverlay(Renderer r) { return r != null && s_overlayRends.Contains(r); } + + /// + /// 몹 피격 점멸(`MobHitFlash`)이 렌더러의 프로퍼티 블록을 통째로 다시 쓸 때 불러, 2등급 본체 그늘 색을 **같은 블록**에 같이 써 넣는다 + /// (따로 쓰면 맞을 때마다 지워져 그늘 색이 깜빡인다 · 블록의 소유자는 MobHitFlash 하나). + /// + public static void WriteTier2Props(Renderer r, MaterialPropertyBlock mpb) + { + if (r == null || mpb == null || !s_overlayRends.Contains(r)) return; + var cfg = WLCharacterBacklightSettings.Instance; + if (cfg == null || cfg.enabled_ == 0 || cfg.tiersEnabled == 0) return; + var baseMat = r.sharedMaterial; + if (baseMat == null) return; + if (baseMat.HasProperty(kGoochDark)) mpb.SetColor(kGoochDark, cfg.tier2MkShadowColor); + if (baseMat.HasProperty(kMinDark) && baseMat.HasProperty(kShadowCol)) + { + mpb.SetFloat(kMinDark, Mathf.Clamp01(baseMat.GetFloat(kMinDark) + cfg.tier2MinimumDarknessAdd)); + mpb.SetColor(kShadowCol, Color.Lerp(baseMat.GetColor(kShadowCol), cfg.tier2ShadowColor, cfg.tier2ShadowBlend)); + } + } public static void ApplyTier2(GameObject root) { @@ -197,8 +221,10 @@ namespace WL.Look.Character r.sharedMaterials = s_tmp.ToArray(); } TintTier2Base(cfg, r, baseMat); + s_overlayRends.Add(r); n++; } + if (s_overlayRends.Count > 512) s_overlayRends.RemoveWhere(x => x == null); Tier2Renderers += n; } diff --git a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset index b6a0bffe1..fed84c551 100644 --- a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset +++ b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset @@ -94,6 +94,7 @@ MonoBehaviour: shoreSprayLineBreak: 0.6 dungeonGrassMatchIsland: 1 dungeonLightMatchIsland: 1 + dungeonTerrainMatchGrassDarkness: 1 shoreFoamMaterial: {fileID: 2100000, guid: d24a4c5ce16646c409631c112b57f2cf, type: 2} shoreFoamWidth: 1.6 shoreFoamInner: 0.5 diff --git a/Assets/WL/Look/Farm/WLDungeonGrassLook.cs b/Assets/WL/Look/Farm/WLDungeonGrassLook.cs index a382b1ea0..5e596acaa 100644 --- a/Assets/WL/Look/Farm/WLDungeonGrassLook.cs +++ b/Assets/WL/Look/Farm/WLDungeonGrassLook.cs @@ -31,6 +31,19 @@ namespace WL.Look.Farm // ── 진단(프로브가 읽는다) public static int Instancers, EntriesScaled, MaterialsTuned, TerrainsTuned, LightsMatched; public static string LastLog = ""; + public static float TerrainDarknessMatched = -1f; + + static float GrassDarkness(DemoLayer layer, Mesh grassMesh) + { + if (layer == null || layer.Settings == null) return -1f; + for (int i = 0; i < layer.Settings.Length; i++) + { + var e = layer.Settings[i]; + if (e == null || e.Material == null || !e.Material.HasProperty("_MinimumDarkness")) continue; + if (grassMesh == null || e.Mesh == grassMesh) return e.Material.GetFloat("_MinimumDarkness"); + } + return -1f; + } static readonly Dictionary s_tuned = new Dictionary(); @@ -43,6 +56,7 @@ namespace WL.Look.Farm int insts = 0, scaled = 0, terrains = 0, lights = 0; int tunedBefore = s_tuned.Count; var roots = scene.GetRootGameObjects(); + float grassMd = -1f; for (int r = 0; r < roots.Length; r++) { var list = roots[r].GetComponentsInChildren(true); @@ -57,13 +71,26 @@ namespace WL.Look.Farm scaled += TuneLayer(cfg, inst.FourthLayer, grassMesh, flowerMesh); if (was) inst.enabled = true; // OnEnable → 새 값으로 다시 심는다 insts++; + if (grassMd < 0f) grassMd = GrassDarkness(inst.FirstLayer, grassMesh); } + } + // 🔴 2026-09-17 PD 「전투맵의 풀이 여전히 자글자글(섬에서 지적한 현상과 동일)」 실측(풀만 있는 구역의 밝기 기울기): + // SSAO·후처리·그림자·풀 외곽선은 무관. 814t 대비 복제가 **지형** 그늘 최소 밝기를 0.12 로 낮춰 풀잎(0.2)보다 그늘이 어두워져 + // 구름 그늘·명암 단계마다 풀잎이 하나씩 밝은 점으로 도드라졌다(0.57). 지형을 풀잎과 같은 값으로 맞추면 0.11(섬과 같은 매끈함). + for (int r = 0; r < roots.Length; r++) + { var ts = roots[r].GetComponentsInChildren(true); for (int i = 0; i < ts.Length; i++) { var t = ts[i]; if (t == null || t.materialTemplate == null) continue; var m = Tuned(cfg, t.materialTemplate); + if (cfg.dungeonTerrainMatchGrassDarkness != 0 && grassMd >= 0f && m.HasProperty("_MinimumDarkness")) + { + if (m == t.materialTemplate && !s_tuned.ContainsValue(m)) { var c = new Material(m) { name = m.name + " (WL dungeon=island)" }; s_tuned[m] = c; m = c; } + m.SetFloat("_MinimumDarkness", grassMd); + TerrainDarknessMatched = grassMd; + } if (m != t.materialTemplate) { t.materialTemplate = m; terrains++; } } @@ -84,7 +111,7 @@ namespace WL.Look.Farm Instancers = insts; EntriesScaled = scaled; TerrainsTuned = terrains; LightsMatched = lights; MaterialsTuned = s_tuned.Count; LastLog = "던전 풀밭 = 섬 값 — 인스턴서 " + insts + " · 크기 바꾼 항목 " + scaled + " · 재질 복사본 " + s_tuned.Count + "(새로 " + (s_tuned.Count - tunedBefore) + ")" - + " · 터레인 " + terrains + " · 태양 " + lights + " @" + scene.name; + + " · 터레인 " + terrains + "(그늘 최소 밝기 = 풀잎 " + TerrainDarknessMatched.ToString("F2") + ") · 태양 " + lights + " @" + scene.name; cfg.Log(LastLog); } diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index 173ba4b0a..28dc8262e 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -316,6 +316,8 @@ namespace WL.Look.Farm public int dungeonGrassMatchIsland = 1; [Tooltip("1 = 던전의 디렉셔널 라이트도 섬의 색·세기·그림자 진하기(`dirLight*`)로 맞춘다.")] public int dungeonLightMatchIsland = 1; + [Tooltip("1 = 던전 지형 재질의 그늘 최소 밝기(_MinimumDarkness)를 풀잎 재질과 같은 값으로 맞춘다. 실측: 814t 대비 복제가 지형만 0.12 로 낮춰 풀잎(0.2)이 그늘마다 밝은 점으로 도드라졌다(자글자글).")] + public int dungeonTerrainMatchGrassDarkness = 1; // ───────────────────────────────── §816zf 부드러운 흙 데칼 [Header("§816zf — 흙 자리를 데모처럼 「둥글게 번지게」 (PD 2026-09-15)")]