[WL] 가격판 겹침·오류 수정 · 예고 섬광 20 % · 넉백 거리 절반 · 히트 하이라이트 매 타격

- 가격판: 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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-18 01:34:11 +09:00
parent 1371982fec
commit b95f043696
12 changed files with 345 additions and 15 deletions

View File

@ -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<Renderer> s_tmpRenderers = new List<Renderer>(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;
}
/// <summary>
/// 2026-09-18 PD 「예고 섬광도 투명도 20 %」 — 공격 예고의 흰 섬광을 덮기 방식(덧그리기 패스 `_HitFlash`)으로 건다. opacity 0 = 끔.
/// 덧그리기 패스가 아직 없으면 붙이고, 그래도 없으면(룩 시스템 꺼짐) false = 호출자가 예전 방식으로 그린다.
/// </summary>
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;
}
/// <summary>엘리트 상시 색을 뗀다(처치·정리). 점멸/디졸브 상태는 건드리지 않는다.</summary>
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 = "";
}
}

View File

@ -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<eCC, CCData> cc;
public double lastBack, lastHitter;
public bool movingBack, movingHitter;
public int stillBack, stillHitter;
public Vector3 restPos, startBack, startHitter;
public float lastActiveAt;
}
/// <summary>넉백 값이 이 틱 수 동안(시간이 흐르는 프레임 기준) 전혀 줄지 않으면 원본 코루틴이 없는 것 = 몹이 영영 묶인다 → 0 으로 풀어 준다.</summary>
const int StuckTicks = 10;
/// <summary>배율 계산에 쓰는 프레임 시간 상한(판 재시작·로딩 순간의 긴 프레임이 계산을 음수로 만들지 않게).</summary>
const float MaxStepSeconds = 0.05f;
static readonly Dictionary<Actor, Track> s_tracks = new Dictionary<Actor, Track>(64);
static readonly List<Track> s_list = new List<Track>(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<eCC, CCData> : 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); }
/// <summary>씬 전환: 추적 목록(파괴된 Actor 참조)을 버린다.</summary>
public static void ClearAll() { s_tracks.Clear(); s_list.Clear(); }
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
ScaledBack = ScaledHitter = EndedBack = EndedHitter = StuckCleared = 0;
SumMovedBack = SumMovedHitter = LastMovedBack = LastMovedHitter = 0f;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99cbc94ae8b7d334aa9275de30b417f9

View File

@ -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.")]

View File

@ -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(); }

View File

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

View File

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

View File

@ -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);
}
/// <summary>
/// 흰 섬광을 건다. 2026-09-18 PD 「투명도 20 % 반투명으로」 — flashOverlayOpacity > 0 이면 피격 하이라이트와 같은 덮기 방식
/// (본체 색·예고 색이 비친다). 덧그리기 패스를 못 쓰면 예전 방식(알베도·에미션 = 완전 흰색).
/// </summary>
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);
}

View File

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

View File

@ -46,4 +46,6 @@ MonoBehaviour:
mobStaggerBlockAttack: 1
mobStaggerFreezeDelay: 0.1
mobStaggerReplayInterval: 0.2
mobKnockbackDistanceScale: 0.5
mobKnockbackScaleByHitter: 1
logHits: 0

View File

@ -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)")]

View File

@ -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, "섬 씬 내려감");
}
/// <summary>파괴된 섬에 묶인 FI 확장 가격판을 지운다. 반환 = 지운 수.</summary>
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<FIPurchaser>(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); }
/// <summary>이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로).</summary>
@ -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))