From b95f04369601010c8679876e3f6f75c983cce786 Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 18 Sep 2026 01:34:11 +0900 Subject: [PATCH] =?UTF-8?q?[WL]=20=EA=B0=80=EA=B2=A9=ED=8C=90=20=EA=B2=B9?= =?UTF-8?q?=EC=B9=A8=C2=B7=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=C2=B7?= =?UTF-8?q?=20=EC=98=88=EA=B3=A0=20=EC=84=AC=EA=B4=91=2020=20%=20=C2=B7=20?= =?UTF-8?q?=EB=84=89=EB=B0=B1=20=EA=B1=B0=EB=A6=AC=20=EC=A0=88=EB=B0=98=20?= =?UTF-8?q?=C2=B7=20=ED=9E=88=ED=8A=B8=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A7=A4=20=ED=83=80=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가격판: FI Purchaser 가 활성 씬(InGame)에 생겨 섬 씬이 내려가도 남던 옛 판(파괴된 섬에 묶임)을 섬 씬 언로드 순간 제거 + 섬 준비 때 한 번 더 실측: 던전 입장 때 1개 제거 · 복귀 후 판 1개 · 겹침 0 · 오류 0 - 공격 예고 흰 섬광: 알베도 2.2/에미션 1.6(완전 흰색) → 캐릭터 덧그리기 패스 _HitFlash 20 %(flashOverlayOpacity) · 실측 완전 흰색 0 - 넉백 거리 배율(MobKnockbackScale · mobKnockbackDistanceScale 0.5): 새 넉백이 시작된 틱에 원본 남은 시간을 줄여 총 거리 절반 실측 평균 0.203 m(38회) → 0.102~0.103 m(84회) · 넉백 상태 멈춤 0(최솟값을 남기면 끊긴 넉백이 영영 안 풀리던 결함은 수정 전 실측 · 멈춘 넉백 풀기 안전장치 포함) - 히트 하이라이트: 꺼진 뒤 0.2 s 쉼 동안 다음 점멸을 막아 빠른 연타에서 첫 타만 번쩍 → 타격마다 다시 켬(retriggerEveryHit) 실측(0.15 s 간격 6타): 종전 3/6 → 6/6 · 실제 전투 화면 캡처로 1~3타 모두 표시 확인 - 원본 0줄 Co-Authored-By: Claude Opus 5 --- Assets/WL/Combat/Reaction/MobHitFlash.cs | 67 ++++++- .../WL/Combat/Reaction/MobKnockbackScale.cs | 183 ++++++++++++++++++ .../Combat/Reaction/MobKnockbackScale.cs.meta | 2 + .../WL/Combat/Reaction/WLHitBlinkSettings.cs | 4 + Assets/WL/Combat/Reaction/WLReactionRunner.cs | 2 + .../Resources/WL/WLHitBlinkSettings.asset | 1 + .../Resources/WL/WLTelegraphSettings.asset | 2 + Assets/WL/Combat/Telegraph/Telegraph.cs | 28 ++- .../Combat/Telegraph/WLTelegraphSettings.cs | 6 + .../Resources/WL/WLHitFeelSettings.asset | 2 + Assets/WL/Feel/WLHitFeelSettings.cs | 8 + Assets/WL/Island/WLIslandFlowV2.cs | 55 ++++++ 12 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 Assets/WL/Combat/Reaction/MobKnockbackScale.cs create mode 100644 Assets/WL/Combat/Reaction/MobKnockbackScale.cs.meta diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs b/Assets/WL/Combat/Reaction/MobHitFlash.cs index bb28fa7a3..21c0908bb 100644 --- a/Assets/WL/Combat/Reaction/MobHitFlash.cs +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -36,9 +36,13 @@ namespace WL.Combat.Reaction public MaterialPropertyBlock[] blocks; public Resolved[] resolved; public bool blinkOn, tint; + // 2026-09-18 — 공격 예고 섬광(Telegraph)을 덮기 방식으로 그릴 때의 불투명도(0 = 없음)와 색 + public float tellFlash; + public Color tellColor; public float blinkOffAt, blinkReadyAt, tintHdr, tintRange; // WL #816zb — 이번 점멸이 처음 켜진 시각. 연장(ExtendCount)이 blinkMaxSeconds 를 넘지 못하게 하는 기준점. public float blinkStartAt; + public int blinkStartFrame = -1; // 2026-09-18 — 같은 프레임 다중 히트(스윙 1회 여러 타)는 한 번으로 묶는다 public int activeIndex = -1; // WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1). public float blinkColorMul = 1f, blinkTimeMul = 1f; @@ -59,7 +63,7 @@ namespace WL.Combat.Reaction 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 int OverlayBlinks, LegacyBlinks, RetriggerCount; // ── 진단(프로브가 읽는다) public static bool Subscribed; @@ -110,6 +114,24 @@ namespace WL.Combat.Reaction float colorMul, timeMul; ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); float now = Time.unscaledTime; + if (st.retriggerEveryHit) + { + // 🔴 2026-09-18 PD 「히트 하이라이트가 최초 공격 1회에만 발생 → 타격될 때마다」 — 실측: 매 타격 값은 들어갔지만(58/58), + // 꺼진 뒤 blinkOffSeconds(0.2 s) 동안 다음 점멸을 막고 켜진 중엔 연장만 해서 빠른 연타·연속 피해에서는 첫 타격만 번쩍였다. + // → 타격마다 처음부터 다시 켠다(쉼·연장 상한 없음). 같은 프레임의 여러 타는 한 번으로 묶는다. + if (s.blinkOn && s.blinkStartFrame == Time.frameCount) { ExtendCount++; return; } + if (s.blinkOn) RetriggerCount++; + if (s.tint && !victim.IsDead()) s.tint = false; + WLReactionRunner.Ensure(); + s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul; + s.blinkOn = true; s.blinkStartAt = now; s.blinkStartFrame = Time.frameCount; + s.blinkOffAt = now + st.blinkOnSeconds * timeMul; + Apply(s, st); + Activate(s); + OnCount++; + LastInfo = "on"; + return; + } // WL #816zb — 연장도 '처음 켜진 시각 + blinkMaxSeconds' 를 넘지 못한다(PD: 몹이 계속 하얗게 보이는 현상). if (s.blinkOn) { s.blinkOffAt = CapOffAt(st, s.blinkStartAt, now + st.blinkOnSeconds * timeMul); ExtendCount++; return; } if (now < s.blinkReadyAt) { ThrottledCount++; return; } @@ -152,8 +174,8 @@ namespace WL.Combat.Reaction if (e.actor == null) return; State s; if (!s_states.TryGetValue(e.actor, out s)) return; - if (!s.blinkOn && !s.tint) return; - s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f; + if (!s.blinkOn && !s.tint && s.tellFlash <= 0f) return; + s.blinkOn = false; s.tint = false; s.tellFlash = 0f; s.blinkReadyAt = 0f; var st = St; // 리뷰 Minor-8ⓒ: st == null(에셋 자체가 없음)이면 Apply 를 못 부르지만, 그 상태에서는 Enabled 가 false 라 애초에 MPB 를 쓴 적이 없다 // (블록이 있다면 RestoreAll 의 st == null 경로가 SetPropertyBlock(null) 로 뗀다) → 잔류 0. ⓑ s_states 는 씬 전환(ClearAll)까지 유지 = 풀 크기 상한. @@ -258,13 +280,14 @@ namespace WL.Combat.Reaction if (eres.hasColor) mpb.SetColor(eres.colorId, s.eliteAlbedo); if (eres.hasEmission) mpb.SetColor(eres.emissionId, s.eliteEmission); } + bool overlayHere = WL.Look.Character.WLCharacterBacklight.HasOverlay(r); + float ov = 0f; Color ovColor = st.overlayColor; if (s.blinkOn) { - if (st.overlayMode && WL.Look.Character.WLCharacterBacklight.HasOverlay(r)) + if (st.overlayMode && overlayHere) { // 흰색을 overlayOpacity 만큼만 덮는다(본체가 비친다) — 임팩트 티어 배수는 1.5 배까지만 - mpb.SetFloat(kHitFlashId, Mathf.Clamp01(st.overlayOpacity * Mathf.Min(s.blinkColorMul, 1.5f))); - mpb.SetColor(kHitFlashColorId, st.overlayColor); + ov = Mathf.Clamp01(st.overlayOpacity * Mathf.Min(s.blinkColorMul, 1.5f)); if (i == 0) OverlayBlinks++; } else @@ -275,6 +298,13 @@ namespace WL.Combat.Reaction if (i == 0) LegacyBlinks++; } } + // 2026-09-18 PD 「예고 섬광도 투명도 20 %」 — 피격 하이라이트와 같은 칸에 더 진한 쪽을 쓴다 + if (s.tellFlash > ov && overlayHere) { ov = s.tellFlash; ovColor = s.tellColor; } + if (ov > 0f) + { + mpb.SetFloat(kHitFlashId, ov); + mpb.SetColor(kHitFlashColorId, ovColor); + } if (s.tint) { if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr); @@ -351,8 +381,8 @@ namespace WL.Combat.Reaction foreach (var kv in s_states) { var s = kv.Value; - if (!s.blinkOn && !s.tint && !s.elite) continue; - s.blinkOn = false; s.tint = false; s.elite = false; + if (!s.blinkOn && !s.tint && !s.elite && s.tellFlash <= 0f) continue; + s.blinkOn = false; s.tint = false; s.elite = false; s.tellFlash = 0f; if (st != null) Apply(s, st); else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(null); } } @@ -388,6 +418,26 @@ namespace WL.Combat.Reaction return true; } + /// + /// 2026-09-18 PD 「예고 섬광도 투명도 20 %」 — 공격 예고의 흰 섬광을 덮기 방식(덧그리기 패스 `_HitFlash`)으로 건다. opacity 0 = 끔. + /// 덧그리기 패스가 아직 없으면 붙이고, 그래도 없으면(룩 시스템 꺼짐) false = 호출자가 예전 방식으로 그린다. + /// + public static bool SetTellFlash(Actor actor, float opacity, Color color) + { + var st = St; + if (actor == null || st == null) return false; + var s = GetState(actor, st); + if (s == null || s.renderers.Length == 0) return false; + if (opacity > 0f && !WL.Look.Character.WLCharacterBacklight.HasOverlay(s.renderers[0])) + { + WL.Look.Character.WLCharacterBacklight.ApplyTier2(actor.gameObject); + if (!WL.Look.Character.WLCharacterBacklight.HasOverlay(s.renderers[0])) return false; + } + s.tellFlash = Mathf.Clamp01(opacity); s.tellColor = color; + Apply(s, st); + return true; + } + /// 엘리트 상시 색을 뗀다(처치·정리). 점멸/디졸브 상태는 건드리지 않는다. public static void ClearEliteTint(Actor actor) { @@ -419,6 +469,7 @@ namespace WL.Combat.Reaction public static void ResetDiagnostics() { OnCount = OffCount = ExtendCount = ThrottledCount = TintCount = ClearCount = SkippedFilter = SkippedNoProperty = StatesCreated = RendererFieldViaReflection = 0; + RetriggerCount = 0; LastInfo = ""; } } diff --git a/Assets/WL/Combat/Reaction/MobKnockbackScale.cs b/Assets/WL/Combat/Reaction/MobKnockbackScale.cs new file mode 100644 index 000000000..9b3b8ff7b --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobKnockbackScale.cs @@ -0,0 +1,183 @@ +// ───────────────────────────────────────────────────────────────────────────── +// MobKnockbackScale.cs — 몹 넉백 이동 거리 배율 · 원본 넉백 코루틴 0줄 · 코루틴 0 · GC 0(추적 목록은 풀 크기 상한) +// +// 2026-09-18 PD 「몬스터 넉백 거리가 너무 멀어. 우선은 절반 정도로 줄이고, +// 추후에는 캐릭터 테이블에서 공격 타수 별 넉백 거리를 조절할 수 있도록 설계해줘.」 +// +// ■ 원본 넉백 2종(Actor.cs) +// KnockBack(time, speed 4) → Co_KnockBack : eCC.KnockBack 의 CCTime 이 0 이 될 때까지 매 프레임 뒤로 speed × dt +// Knockback_byHitter(pos, time, 10) → Co_Knockback_byHitter : eCC.KnockBack_byHitter 의 CCTime 동안 때린 쪽 반대로 speed × dt +// 둘 다 「남은 시간 × 속도」만큼 움직이므로, 새 넉백이 시작된 직후 남은 시간을 줄이면 총 거리가 같은 비율로 준다. +// ■ 검출 = 피격(HitConfirmed)된 몹만 추적 · 매 틱 CCTime 이 직전 값보다 커지면 「새 넉백 시작」. +// 그 사이 원본이 이미 한 프레임(dt) 밀었으므로 남은 시간 = (현재 + dt) × 배율 − dt → 총 거리 = 원래 × 배율. +// ■ 값 = WLHitFeelSettings.mobKnockbackDistanceScale(0.5) · mobKnockbackScaleByHitter · 0 이하 = 기능 끔(원본 그대로). +// ■ 진단: 종류별 배율 적용 수 · 실제 이동 거리(넉백 직전 위치 → 끝 위치 수평 거리) 합·최근값. 원래 거리는 같은 세션에서 배율 1 로 재서 비교한다. +// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class MobKnockbackScale + { + sealed class Track + { + public Actor actor; + public Dictionary cc; + public double lastBack, lastHitter; + public bool movingBack, movingHitter; + public int stillBack, stillHitter; + public Vector3 restPos, startBack, startHitter; + public float lastActiveAt; + } + + /// 넉백 값이 이 틱 수 동안(시간이 흐르는 프레임 기준) 전혀 줄지 않으면 원본 코루틴이 없는 것 = 몹이 영영 묶인다 → 0 으로 풀어 준다. + const int StuckTicks = 10; + /// 배율 계산에 쓰는 프레임 시간 상한(판 재시작·로딩 순간의 긴 프레임이 계산을 음수로 만들지 않게). + const float MaxStepSeconds = 0.05f; + + static readonly Dictionary s_tracks = new Dictionary(64); + static readonly List s_list = new List(64); + static FieldInfo s_ccField; + static bool s_ccLooked; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int ScaledBack, ScaledHitter, EndedBack, EndedHitter, StuckCleared; + public static float SumMovedBack, SumMovedHitter, LastMovedBack, LastMovedHitter, LastScale = -1f; + public static int TrackCount { get { return s_list.Count; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + Subscribed = true; + } + + static WLHitFeelSettings St { get { return WL.Feel.WLHitFeel.Settings; } } + + public static bool Enabled + { + get { var st = St; return st != null && st.enabled && st.mobKnockbackDistanceScale > 0f && !WL.Feel.WLHitFeel.RuntimeDisabled; } + } + + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!Enabled) return; + var victim = e.victim; + if (victim == null || !(victim is MobActor) || victim.IsDead()) return; + Track t; + if (!s_tracks.TryGetValue(victim, out t)) + { + if (!s_ccLooked) + { + s_ccLooked = true; + s_ccField = typeof(Actor).GetField("dic_ccData", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + } + var dict = s_ccField != null ? s_ccField.GetValue(victim) as Dictionary : null; + if (dict == null) return; + // 직전 값은 0(넉백 아님)으로 시작한다 — 이 피격이 방금 건 넉백도 다음 틱에 「새 넉백」으로 잡힌다 + t = new Track { actor = victim, cc = dict, restPos = victim.transform.position }; + s_tracks.Add(victim, t); + s_list.Add(t); + } + t.lastActiveAt = Time.unscaledTime; + WLReactionRunner.Ensure(); + } + + internal static void Tick(float now) + { + if (s_list.Count == 0) return; + var st = St; + bool on = Enabled; + float scale = on ? Mathf.Clamp01(st.mobKnockbackDistanceScale) : 1f; + bool byHitter = on && st.mobKnockbackScaleByHitter; + float dt = Time.deltaTime; + LastScale = scale; + + for (int i = s_list.Count - 1; i >= 0; i--) + { + var t = s_list[i]; + var a = t.actor; + if (a == null || !a.isActiveAndEnabled || a.IsDead() || (now - t.lastActiveAt > 5f && !t.movingBack && !t.movingHitter)) + { + if (!ReferenceEquals(a, null)) s_tracks.Remove(a); + s_list.RemoveAt(i); + continue; + } + var tr = a.transform; + CCData d; + bool idle = true; + if (t.cc.TryGetValue(eCC.KnockBack, out d)) + { + Step(t, d, ref t.lastBack, ref t.movingBack, ref t.startBack, ref t.stillBack, tr, scale, dt, ref ScaledBack, ref EndedBack, ref SumMovedBack, ref LastMovedBack); + if (t.movingBack) idle = false; + } + if (t.cc.TryGetValue(eCC.KnockBack_byHitter, out d)) + { + Step(t, d, ref t.lastHitter, ref t.movingHitter, ref t.startHitter, ref t.stillHitter, tr, byHitter ? scale : 1f, dt, ref ScaledHitter, ref EndedHitter, ref SumMovedHitter, ref LastMovedHitter); + if (t.movingHitter) idle = false; + } + if (idle) t.restPos = tr.position; // 넉백 직전 위치(다음 넉백의 시작점) + else t.lastActiveAt = now; + } + } + + static void Step(Track t, CCData d, ref double last, ref bool moving, ref Vector3 start, ref int still, Transform tr, float scale, float dt, + ref int scaledCount, ref int endedCount, ref float sumMoved, ref float lastMoved) + { + double cc = d.CCTime; + if (cc > last + 0.0001) + { + // 새 넉백 시작(또는 진행 중 재시작) — 진행 중이었다면 지금까지 움직인 거리를 먼저 마감 + if (moving) { lastMoved = Flat(start, tr.position); sumMoved += lastMoved; endedCount++; start = tr.position; } + else start = t.restPos; + moving = true; + still = 0; + // 🔴 직전 틱에 넉백이 아니었을 때만(= 원본 코루틴이 방금 시작) 줄인다. 계산이 0 이하면 0 — 양수 최솟값을 남기면 + // 코루틴이 없는 경우(판 재시작으로 끊긴 넉백 등) 그 값이 영영 안 줄어 몹이 넉백 상태에 묶였다(2026-09-18 실측 · 0.0001 고정). + if (scale < 0.999f && last <= 0.0) + { + float step = Mathf.Min(dt, MaxStepSeconds); + double adj = (cc + step) * scale - step; + d.CCTime = adj > 0.0 ? adj : 0.0; + cc = d.CCTime; + scaledCount++; + } + } + else if (cc > 0.0) + { + // 줄지 않는 넉백 = 원본 코루틴이 없다(끊긴 채 값만 남음) → 몹이 영영 묶이지 않게 풀어 준다(시간이 멈춘 히트스톱 프레임은 세지 않는다) + if (dt > 0f && System.Math.Abs(cc - last) < 1e-9) + { + if (++still >= StuckTicks) { d.CCTime = 0.0; cc = 0.0; still = 0; StuckCleared++; } + } + else still = 0; + } + if (moving && cc <= 0.0) + { + lastMoved = Flat(start, tr.position); + sumMoved += lastMoved; + endedCount++; + moving = false; + } + last = cc; + } + + static float Flat(Vector3 a, Vector3 b) { a.y = 0f; b.y = 0f; return Vector3.Distance(a, b); } + + /// 씬 전환: 추적 목록(파괴된 Actor 참조)을 버린다. + public static void ClearAll() { s_tracks.Clear(); s_list.Clear(); } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + ScaledBack = ScaledHitter = EndedBack = EndedHitter = StuckCleared = 0; + SumMovedBack = SumMovedHitter = LastMovedBack = LastMovedHitter = 0f; + } + } +} diff --git a/Assets/WL/Combat/Reaction/MobKnockbackScale.cs.meta b/Assets/WL/Combat/Reaction/MobKnockbackScale.cs.meta new file mode 100644 index 000000000..fd6437859 --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobKnockbackScale.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 99cbc94ae8b7d334aa9275de30b417f9 \ No newline at end of file diff --git a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs index 2a4834ce2..0b1d88295 100644 --- a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs +++ b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs @@ -97,6 +97,10 @@ namespace WL.Combat.Reaction // 원인(값 계산): 켜짐 최대 0.06 s / 쉼 0.03 s 라 연속 피격 중엔 최대 66 % 시간이 흰색 + 알베도 HDR 3배 = 완전 흰색. // 🔴 하얗게 **오래** 남던 주원인은 이 점멸이 아니라 공격 예고의 흰 섬광이 끊긴 공격에 남은 것(실측 · `WLTelegraphSettings.endOnAttackInterrupted`). // → 켜짐 0.07(상한 0.09) · 쉼 0.2 s(= 연타 중에도 또렷이 끊어지는 점멸) · 그리기는 캐릭터 덧그리기 패스(`WL/CharBacklight`)의 `_HitFlash` 로 **흰색을 20 % 만 덮는다**. + [Tooltip("true = 타격마다 점멸을 처음부터 다시 켠다(아래 blinkOffSeconds 쉼·blinkMaxSeconds 연장 상한을 쓰지 않음 · 같은 프레임 여러 타는 1회).\n" + + "2026-09-18 PD 「히트 하이라이트가 최초 공격 1회에만 발생 → 타격될 때마다」. false = 종전(쉼 동안 다음 점멸 생략).")] + public bool retriggerEveryHit = true; + [Tooltip("true = 덧그리기 패스가 붙은 렌더러는 흰색을 overlayOpacity 만큼만 덮는다(본체 색이 비친다). false 또는 패스가 없으면 예전 방식(알베도 HDR = 완전 흰색).")] public bool overlayMode = true; [Tooltip("덮는 정도(0~1). PD 2026-09-17 = 0.2.")] diff --git a/Assets/WL/Combat/Reaction/WLReactionRunner.cs b/Assets/WL/Combat/Reaction/WLReactionRunner.cs index c7d135b09..23d14e75b 100644 --- a/Assets/WL/Combat/Reaction/WLReactionRunner.cs +++ b/Assets/WL/Combat/Reaction/WLReactionRunner.cs @@ -33,6 +33,7 @@ namespace WL.Combat.Reaction DamageAggregator.ClearAll(); MobHitFlash.ClearAll(); MobStagger.ClearAll(); // WL #816zb — 씬을 넘기며 speed 0 인 몹이 남지 않게 + MobKnockbackScale.ClearAll(); } void Update() @@ -42,6 +43,7 @@ namespace WL.Combat.Reaction DamageAggregator.Tick(now); MobHitFlash.Tick(now); MobStagger.Tick(now); // WL #816zb — 피격 경직 해제 + MobKnockbackScale.Tick(now); // 2026-09-18 — 넉백 거리 배율 } void OnDisable() { MobHitFlash.RestoreAll(); MobStagger.RestoreAll(); KillChain.ReleaseBurst(); } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset index d2002854e..48666f4ec 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset @@ -23,6 +23,7 @@ MonoBehaviour: blinkOnSeconds: 0.07 blinkMaxSeconds: 0.09 blinkOffSeconds: 0.2 + retriggerEveryHit: 1 overlayMode: 1 overlayOpacity: 0.2 overlayColor: {r: 1, g: 1, b: 1, a: 1} diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset index 0ef2c166d..36e1706a4 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset @@ -69,6 +69,8 @@ MonoBehaviour: flashSeconds: 0.05 flashAlbedo: {r: 2.2, g: 2.2, b: 2.2, a: 1} flashEmission: {r: 1.6, g: 1.6, b: 1.6, a: 1} + flashOverlayOpacity: 0.2 + flashOverlayColor: {r: 1, g: 1, b: 1, a: 1} pulseEnabled: 1 pulseScale: 1.12 pulseRestoreGuardSeconds: 0.2 diff --git a/Assets/WL/Combat/Telegraph/Telegraph.cs b/Assets/WL/Combat/Telegraph/Telegraph.cs index e443c16a5..c9c9d43f0 100644 --- a/Assets/WL/Combat/Telegraph/Telegraph.cs +++ b/Assets/WL/Combat/Telegraph/Telegraph.cs @@ -45,6 +45,7 @@ namespace WL.Combat.Telegraph public float startTime, plannedSeconds, hitSeconds, origSpeed, appliedSpeed, targetSeconds; public float fadeUntil, guardUntil; public bool speedApplied, tinted, pulsed, flashed, wasElite, corrected; + public bool overlayFlash; // 2026-09-18 — 섬광을 덮기 방식(MobHitFlash.SetTellFlash)으로 걸었는가 public Vector3 baseScale; public int decalIdx; public int attackIndex; @@ -294,9 +295,8 @@ namespace WL.Combat.Telegraph // 히트 직전 흰 섬광 if (st.flashEnabled && !s.flashed && (planned - elapsed) <= st.flashLeadSeconds) { - if (WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) - { s.flashed = true; s.tinted = true; FlashCount++; } - else s.flashed = true; + if (SetFlash(s, st)) FlashCount++; + s.flashed = true; } } else if (s.phase == Phase.Fading) @@ -377,7 +377,7 @@ namespace WL.Combat.Telegraph static void EndInternal(State s, bool byHit, bool forced) { - if (s.phase == Phase.None && !s.speedApplied && !s.tinted && !s.pulsed && s.decalIdx < 0) return; + if (s.phase == Phase.None && !s.speedApplied && !s.tinted && !s.overlayFlash && !s.pulsed && s.decalIdx < 0) return; var st = St; float now = TelegraphRunner.Now; @@ -401,8 +401,7 @@ namespace WL.Combat.Telegraph if (byHit && !forced && st != null && st.flashEnabled && st.flashSeconds > 0f && s.actor != null) { - if (!s.flashed && WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) - { s.tinted = true; FlashCount++; } + if (!s.flashed && SetFlash(s, st)) FlashCount++; s.flashed = true; s.phase = Phase.Fading; s.fadeUntil = now + st.flashSeconds; @@ -418,8 +417,23 @@ namespace WL.Combat.Telegraph if (s.guardUntil <= now && s.phase == Phase.None) Deactivate(s); } + /// + /// 흰 섬광을 건다. 2026-09-18 PD 「투명도 20 % 반투명으로」 — flashOverlayOpacity > 0 이면 피격 하이라이트와 같은 덮기 방식 + /// (본체 색·예고 색이 비친다). 덧그리기 패스를 못 쓰면 예전 방식(알베도·에미션 = 완전 흰색). + /// + static bool SetFlash(State s, WLTelegraphSettings st) + { + if (s.actor == null) return false; + if (st.flashOverlayOpacity > 0f && WL.Combat.Reaction.MobHitFlash.SetTellFlash(s.actor, st.flashOverlayOpacity, st.flashOverlayColor)) + { s.overlayFlash = true; return true; } + if (WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) + { s.tinted = true; return true; } + return false; + } + static void ClearTint(State s, WLTelegraphSettings st) { + if (s.overlayFlash) { s.overlayFlash = false; if (s.actor != null) WL.Combat.Reaction.MobHitFlash.SetTellFlash(s.actor, 0f, Color.white); } if (!s.tinted || s.actor == null) { s.tinted = false; return; } s.tinted = false; @@ -435,7 +449,7 @@ namespace WL.Combat.Telegraph static void HardClear(State s) { if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } - s.phase = Phase.None; s.tinted = false; s.pulsed = false; s.speedApplied = false; s.guardUntil = 0f; + s.phase = Phase.None; s.tinted = false; s.overlayFlash = false; s.pulsed = false; s.speedApplied = false; s.guardUntil = 0f; Deactivate(s); } diff --git a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs index 442b58890..92625543d 100644 --- a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs +++ b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs @@ -222,9 +222,15 @@ namespace WL.Combat.Telegraph [Tooltip("흰 섬광이 유지되는 시간(초 · 60 fps 기준 0.05 ≈ 3프레임).")] public float flashSeconds = 0.05f; + [Tooltip("예전 방식(아래 덮기 불투명도 0 또는 덧그리기 패스 없음)의 알베도 색 — 완전 흰색.")] [ColorUsage(true, true)] public Color flashAlbedo = new Color(2.2f, 2.2f, 2.2f, 1f); [ColorUsage(true, true)] public Color flashEmission = new Color(1.6f, 1.6f, 1.6f, 1f); + // 2026-09-18 PD 「(예고 섬광도) 투명도 20 % 반투명으로 낮춰」 — 피격 하이라이트(WLHitBlinkSettings.overlayOpacity)와 같은 덮기 방식 + [Tooltip("흰 섬광을 캐릭터 덧그리기 패스로 이만큼만 덮는다(0~1 · 본체·예고 색이 비친다). 0 = 예전 방식(flashAlbedo · 완전 흰색).")] + [Range(0f, 1f)] public float flashOverlayOpacity = 0.2f; + public Color flashOverlayColor = Color.white; + [Header("ⓓ 스케일 펄스 (813s EliteMarker 와 같은 localScale 경로 · 원 스케일 복원)")] [Tooltip("끄면 크기를 건드리지 않는다.")] public bool pulseEnabled = true; diff --git a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset index 7bf0b331f..c22f452be 100644 --- a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset +++ b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset @@ -46,4 +46,6 @@ MonoBehaviour: mobStaggerBlockAttack: 1 mobStaggerFreezeDelay: 0.1 mobStaggerReplayInterval: 0.2 + mobKnockbackDistanceScale: 0.5 + mobKnockbackScaleByHitter: 1 logHits: 0 diff --git a/Assets/WL/Feel/WLHitFeelSettings.cs b/Assets/WL/Feel/WLHitFeelSettings.cs index bf40e772d..2c1f3c260 100644 --- a/Assets/WL/Feel/WLHitFeelSettings.cs +++ b/Assets/WL/Feel/WLHitFeelSettings.cs @@ -172,6 +172,14 @@ public class WLHitFeelSettings : ScriptableObject [Tooltip("연타 중 피격 모션을 다시 트는 최소 간격(초). 너무 짧으면 첫 프레임만 반복돼 떨려 보인다.")] public float mobStaggerReplayInterval = 0.2f; + // ── 2026-09-18 PD 「몬스터 넉백 거리가 너무 멀어. 우선은 절반 정도로 줄이고, 추후 캐릭터 테이블에서 공격 타수별 넉백 거리를 조절」 + // 원본 넉백 = 남은 시간(CCTime) × 속도로 민다(뒤로 밀기 초속 4 m · 때린 쪽 반대로 밀기 초속 10 m). 새 넉백이 시작된 프레임에 + // **남은 시간을 줄여** 총 이동 거리를 배율만큼으로 만든다(MobKnockbackScale · 원본 코드 0줄). 타수별 테이블 값은 설계서(인계 델타 추가분 35) 참조. + [Tooltip("몹 넉백 이동 거리 배율. 1 = 원본 · 0.5 = 절반. 0 이하 = 기능 끔(원본 그대로).")] + [Range(0f, 1f)] public float mobKnockbackDistanceScale = 0.5f; + [Tooltip("때린 쪽 반대로 미는 넉백(스킬 추가효과 KnockBack_byHitter · 초속 10 m)에도 같은 배율을 쓴다.")] + public bool mobKnockbackScaleByHitter = true; + // ─────────────────────────────────────────── 진단 [Header("진단 (검증용 · 배포 시 false)")] diff --git a/Assets/WL/Island/WLIslandFlowV2.cs b/Assets/WL/Island/WLIslandFlowV2.cs index a5f502e32..1fb202a91 100644 --- a/Assets/WL/Island/WLIslandFlowV2.cs +++ b/Assets/WL/Island/WLIslandFlowV2.cs @@ -873,6 +873,60 @@ namespace WL.Island static void OnTerrainBaked() { if (s_cfgForBaked != null) RefitPlaced(s_cfgForBaked); } + // ───────────────────────────────────────────────────────────────── + // 🔴 2026-09-18 PD 「가격판 겹침 오류 수정」 — 실측: FI `Purchaser` 는 부모 없이 Instantiate 돼 **활성 씬(InGame)** 에 생기고, + // 던전에 들어갈 때 섬 씬만 내려가 섬(`Island`)은 파괴되고 판은 남았다 → 섬에 돌아오면 새 판과 같은 자리에 겹치고, + // 옛 판에서 구매가 끝나면 코인을 뺀 뒤 `island.Activate()` 가 파괴된 섬에 접근(MissingReferenceException → 원본 오류 창 · 「0」 판 잔존). + // → 섬 씬이 내려가는 순간 「파괴된 섬에 묶인 판」을 지운다(구매 진행분은 FI 세이브 `PurchaserData.Price` 에 이미 남아 새 판이 이어받는다). + // 섬이 준비될 때(Apply)도 한 번 더 훑는다(안전장치). 섬 참조가 비어 있는 판(Init 전 · 우리 발판)은 건드리지 않는다. + // ───────────────────────────────────────────────────────────────── + static System.Reflection.FieldInfo s_purchIslandField; + static bool s_purchIslandLooked; + public static int StaleRemoved; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void HookSceneUnload() + { + SceneManager.sceneUnloaded -= OnSceneUnloaded; + SceneManager.sceneUnloaded += OnSceneUnloaded; + } + + static void OnSceneUnloaded(Scene scene) + { + var cfg = WLIslandSettings.Instance; + if (cfg == null || string.IsNullOrEmpty(cfg.islandSceneName) || scene.name != cfg.islandSceneName) return; + RemoveStalePurchasers(cfg, "섬 씬 내려감"); + } + + /// 파괴된 섬에 묶인 FI 확장 가격판을 지운다. 반환 = 지운 수. + public static int RemoveStalePurchasers(WLIslandSettings cfg, string reason) + { + if (!s_purchIslandLooked) + { + s_purchIslandLooked = true; + s_purchIslandField = typeof(FIPurchaser).GetField("island", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + } + if (s_purchIslandField == null) return 0; + var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + int n = 0; + for (int i = 0; i < all.Length; i++) + { + var p = all[i]; + if (p == null) continue; + var isl = s_purchIslandField.GetValue(p) as Object; + if (ReferenceEquals(isl, null) || isl != null) continue; // 묶인 적 없음 · 섬이 살아 있음 + Object.Destroy(p.gameObject); + n++; + } + if (n > 0) + { + StaleRemoved += n; + LastLog = "옛 확장 가격판 " + n + "개 제거(" + reason + " · 파괴된 섬에 묶인 판)"; + if (cfg != null) WLIslandBridge.Log(cfg, LastLog); + } + return n; + } + static IEnumerator Co_RefitNextFrame(WLIslandSettings cfg) { yield return null; RefitPlaced(cfg); } /// 이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로). @@ -925,6 +979,7 @@ namespace WL.Island { if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return; + RemoveStalePurchasers(cfg, "섬 준비"); // 2026-09-18 — 던전 복귀 뒤 옛 판(파괴된 섬에 묶임) 안전장치 Watch(cfg, scene); // 칸이 열리는 프레임에 바로 옮기기 · 지형 재굽기 뒤 높이 재맞춤 PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회) if (IsUnlocked(cfg))