[WL-816zb] 피격 경직 + 흰색 하이라이트 단축 (#816)

경직 = MobStagger(신규 · HitConfirmed/Killed/Spawned 구독 · 원본 0줄) · NavMeshAgent.speed 0 + velocity 0 + Animator.speed 0
       일반 0.18 / 엘리트 0.12 / 보스 0.08 s · 넉백 중에는 종료를 미뤄 넉백 직후부터 다시 셈(상한 1 s) · 스턴·빙결이면 즉시 해제
       값 = WLHitFeelSettings(mobStaggerEnabled·초 3개) + 에셋

흰색 = MobHitFlash 점멸 상한 blinkMaxSeconds 0.06 s (연장·티어 시간 배수 2.4 포함해 첫 점멸 시작 기준으로 절단)
       세기 blinkColor 2.2→3.0 · blinkEmission 0.6→0.9 (짧게, 대신 세게) · 값 = WLHitBlinkSettings + 에셋

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-15 15:56:56 +09:00
parent 2799b311b6
commit e692c53ea0
8 changed files with 329 additions and 10 deletions

View File

@ -37,6 +37,8 @@ namespace WL.Combat.Reaction
public Resolved[] resolved; public Resolved[] resolved;
public bool blinkOn, tint; public bool blinkOn, tint;
public float blinkOffAt, blinkReadyAt, tintHdr, tintRange; public float blinkOffAt, blinkReadyAt, tintHdr, tintRange;
// WL #816zb — 이번 점멸이 처음 켜진 시각. 연장(ExtendCount)이 blinkMaxSeconds 를 넘지 못하게 하는 기준점.
public float blinkStartAt;
public int activeIndex = -1; public int activeIndex = -1;
// WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1). // WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1).
public float blinkColorMul = 1f, blinkTimeMul = 1f; public float blinkColorMul = 1f, blinkTimeMul = 1f;
@ -103,13 +105,15 @@ namespace WL.Combat.Reaction
float colorMul, timeMul; float colorMul, timeMul;
ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul);
float now = Time.unscaledTime; float now = Time.unscaledTime;
if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds * timeMul; ExtendCount++; 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; } if (now < s.blinkReadyAt) { ThrottledCount++; return; }
if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리 if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리
WLReactionRunner.Ensure(); WLReactionRunner.Ensure();
s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul; s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul;
s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds * timeMul; s.blinkOn = true; s.blinkStartAt = now;
s.blinkOffAt = CapOffAt(st, now, now + st.blinkOnSeconds * timeMul);
Apply(s, st); Apply(s, st);
Activate(s); Activate(s);
OnCount++; OnCount++;
@ -265,6 +269,18 @@ namespace WL.Combat.Reaction
} }
} }
/// <summary>
/// WL #816zb — 점멸 종료 시각의 상한. startAt + blinkMaxSeconds 를 넘지 못한다.
/// blinkMaxSeconds ≤ 0 이면 상한 없음 = 기존 동작 그대로(C8 롤백 경로).
/// </summary>
static float CapOffAt(WLHitBlinkSettings st, float startAt, float wantOffAt)
{
float max = st.blinkMaxSeconds;
if (max <= 0f) return wantOffAt;
float limit = startAt + max;
return wantOffAt < limit ? wantOffAt : limit;
}
/// <summary>WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0).</summary> /// <summary>WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0).</summary>
static Color ScaleRgb(Color c, float m) static Color ScaleRgb(Color c, float m)
{ {

View File

@ -0,0 +1,261 @@
// ─────────────────────────────────────────────────────────────────────────────
// MobStagger.cs — 피격 몹 짧은 경직(스태거) · 원본 훅 0줄 · 코루틴 0 · GC 0
//
// PD 지시 #816zb : 「피격 된 적에게 경직이 없는 것 같아. 밀린 상태에서 다시 이동하려고 하기 때문에 어색하고
// 액션 게임의 타격감을 느끼기 어려워. 피격 된 적은 짧게 경직 효과을 적용해서 타격감을 살려줘.」
//
// ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · Assets/Script 무수정)
// HitConfirmed(몹 · 피해 > 0 · 이 타격으로 죽지 않음)
// → 그 몹의 NavMeshAgent.speed 를 0 으로 잠그고 velocity 를 지우고 Animator.speed 를 0 으로 세운다.
// 등급별 시간(일반 0.18 · 엘리트 0.12 · 보스 0.08 s · WLHitFeelSettings)이 지나면 원래 값으로 되돌린다.
// → 다시 맞으면 시간이 갱신된다(연타 중에는 계속 붙잡혀 있다 = 히트 락).
// Killed / Spawned(풀 재사용) → 즉시 원복하고 상태를 놓는다.
//
// ■ 왜 isStopped 가 아니라 speed 인가
// MobActor 의 상태 기계가 Chase/Patrol 전이마다 `m_NavMeshAgent.isStopped = false` 를 직접 쓴다(MobActor.cs:304·309·339).
// isStopped 로 막으면 같은 프레임에 되돌려져 경직이 먹지 않고, NavMesh 밖/비활성 에이전트에 쓰면 "Resume" 예외가 난다(Q3 D-1).
// speed 는 몹 스폰(MobActor.cs:113)·이동 버프(Actor.Add_MoveSpeed) 외에는 매 프레임 덮어쓰이지 않으므로
// 매 틱 0 을 다시 써 두면 AI 와 싸우지 않고 확실히 멈춘다. 회전(angularSpeed)은 그대로 둬 바라보기는 유지된다.
//
// ■ 넉백(원본)과의 관계 — PD 「밀린 상태에서 다시 이동하려고 하기 때문에 어색하고」
// 원본 넉백은 transform.Translate 로 미는 코루틴(Actor.Co_KnockBack)이라 speed 0 과 충돌하지 않는다.
// 넉백이 진행 중인 동안은 경직 종료 시각을 계속 뒤로 밀어, **넉백이 끝난 시점부터** 경직 시간을 다시 준다
// (밀리다 멈추자마자 걸어 나오는 그 어색함이 원인). 무한 연장 방지로 피격 후 HoldMaxSeconds 까지만 민다.
// 스턴·빙결 등 게임 자체 CC(IsCC(ignoreknockback:true))가 걸리면 그 쪽이 몹을 소유하므로 우리는 즉시 손을 뗀다
// (긴 스턴 동안 애니메이터를 얼려 두는 사고 방지).
//
// ■ 되돌리기 안전 — 우리가 쓴 0 이 그대로 남아 있을 때만 원래 값으로 복원한다(그 사이 버프·사망·리스폰이 바꿨으면 건드리지 않는다).
// WLHitFeelSettings.mobStaggerEnabled = 0(또는 enabled = 0 · RuntimeDisabled)이면 아무 것도 하지 않는다 = 기존 동작 100 %. 롤백 스위치.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
public static class MobStagger
{
/// <summary>넉백이 비정상적으로 길 때를 대비한 연장 상한(초 · 피격 시각 기준). 기본 넉백 f_KnockbackTime 은 이보다 훨씬 짧다.</summary>
const float HoldMaxSeconds = 1f;
sealed class State
{
public Actor actor;
public NavMeshAgent agent;
public Animator anim;
public float until, hitAt, duration;
public float savedSpeed, savedAnimSpeed;
public bool speedWritten, animWritten;
public int activeIndex = -1;
}
static readonly Dictionary<Actor, State> s_states = new Dictionary<Actor, State>(64);
static State[] s_active = new State[32];
static int s_activeCount;
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int StaggerCount, RefreshCount, ReleasedByCcCount, HoldCount, SkippedFilter, SkippedNoAgent, EndedCount;
public static float LastDuration;
public static int ActiveCount { get { return s_activeCount; } }
public static int StateCount { get { return s_states.Count; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
Subscribed = true;
}
static WLHitFeelSettings St { get { return WL.Feel.WLHitFeel.Settings; } }
/// <summary>설정 에셋이 없거나 전체 스위치·경직 스위치가 꺼져 있으면 이 시스템은 통째로 비활성.</summary>
public static bool Enabled
{
get { var st = St; return st != null && st.enabled && st.mobStaggerEnabled && !WL.Feel.WLHitFeel.RuntimeDisabled; }
}
// ───────────────────────────────────────── 이벤트
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!Enabled) return;
var victim = e.victim;
if (victim == null || !(victim is MobActor)) { SkippedFilter++; return; }
if (e.damage <= 0d || e.isKill || victim.IsDead()) { SkippedFilter++; return; } // 무효타·처치타는 경직 없음(사망 모션을 얼리지 않는다)
var st = St;
float dur = victim.IsSubRole(eSubRol.Boss) ? st.mobStaggerSecondsBoss
: victim.IsSubRole(eSubRol.Elite) ? st.mobStaggerSecondsElite
: st.mobStaggerSeconds;
if (dur <= 0.0001f) { SkippedFilter++; return; }
var s = GetState(victim);
if (s == null) { SkippedNoAgent++; return; }
float now = Time.unscaledTime;
if (s.activeIndex >= 0) RefreshCount++; else StaggerCount++;
s.hitAt = now;
s.duration = dur;
s.until = now + dur;
LastDuration = dur;
WLReactionRunner.Ensure();
Freeze(s);
Activate(s);
}
static void OnKilled(in KilledEvent e) { Release(e.victim); }
static void OnSpawned(in SpawnedEvent e) { Release(e.actor); }
static void Release(Actor actor)
{
State s;
if (actor == null || !s_states.TryGetValue(actor, out s)) return;
Restore(s);
Deactivate(s);
}
// ───────────────────────────────────────── 상태
static State GetState(Actor actor)
{
State s;
if (s_states.TryGetValue(actor, out s))
{
if (s.agent == null) s.agent = actor.Get_Agent(); // 풀 재사용 중 에이전트가 붙었을 수 있다
if (s.anim == null) s.anim = actor.m_animation;
return (s.agent != null || s.anim != null) ? s : null;
}
var agent = actor.Get_Agent();
var anim = actor.m_animation;
if (agent == null && anim == null) return null; // 멈출 수단이 없다 — 상태를 만들지 않는다
s = new State { actor = actor, agent = agent, anim = anim };
s_states.Add(actor, s);
return s;
}
/// <summary>speed 0 · velocity 0 · Animator.speed 0. 원래 값은 처음 얼릴 때 1회만 기억한다.</summary>
static void Freeze(State s)
{
var a = s.agent;
if (a != null)
{
if (!s.speedWritten) { s.savedSpeed = a.speed; s.speedWritten = true; }
a.speed = 0f;
// 비활성(넉백_byHitter 가 agent.enabled = false)·NavMesh 밖 에이전트에 velocity 를 쓰면 예외가 난다.
if (a.enabled && a.isOnNavMesh) a.velocity = Vector3.zero;
}
var an = s.anim;
if (an != null)
{
if (!s.animWritten) { s.savedAnimSpeed = an.speed; s.animWritten = true; }
an.speed = 0f;
}
}
/// <summary>우리가 쓴 0 이 그대로 남아 있을 때만 원래 값으로 되돌린다(버프·사망·리스폰이 바꿨으면 손대지 않는다).</summary>
static void Restore(State s)
{
if (s.speedWritten)
{
var a = s.agent;
if (a != null && a.speed == 0f) a.speed = s.savedSpeed;
s.speedWritten = false;
}
if (s.animWritten)
{
var an = s.anim;
if (an != null && an.speed == 0f) an.speed = s.savedAnimSpeed;
s.animWritten = false;
}
}
static void Activate(State s)
{
if (s.activeIndex >= 0) return;
if (s_activeCount == s_active.Length) System.Array.Resize(ref s_active, s_active.Length * 2);
s.activeIndex = s_activeCount;
s_active[s_activeCount++] = s;
}
static void Deactivate(State s)
{
int i = s.activeIndex;
if (i < 0) return;
int last = --s_activeCount;
s_active[i] = s_active[last];
if (s_active[i] != null) s_active[i].activeIndex = i;
s_active[last] = null;
s.activeIndex = -1;
}
internal static void Tick(float now)
{
if (s_activeCount == 0) return;
bool on = Enabled;
for (int i = s_activeCount - 1; i >= 0; i--)
{
var s = s_active[i];
var actor = s.actor;
if (!on || actor == null || !actor.isActiveAndEnabled || actor.IsDead())
{
Restore(s); Deactivate(s); EndedCount++; continue;
}
// 스턴·빙결·공중 띄우기 등 게임 자체 CC 가 걸리면 그 쪽이 몹을 소유한다 — 우리는 손을 뗀다.
if (actor.IsCC(true))
{
Restore(s); Deactivate(s); ReleasedByCcCount++; continue;
}
// 남은 CC = 넉백. 밀리는 동안은 종료 시각을 뒤로 밀어 '넉백이 끝난 시점부터' 경직 시간을 준다.
if (actor.IsCC() && now < s.hitAt + HoldMaxSeconds)
{
s.until = now + s.duration;
HoldCount++;
}
if (now >= s.until)
{
Restore(s); Deactivate(s); EndedCount++; continue;
}
Freeze(s); // AI 상태 전이가 speed 를 되돌려도 매 틱 다시 0 으로 잠근다
}
}
/// <summary>모든 몹의 speed·Animator 를 되돌린다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지.</summary>
public static void RestoreAll()
{
foreach (var kv in s_states) Restore(kv.Value);
for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; }
s_activeCount = 0;
}
/// <summary>씬 전환: 되돌리고 상태 캐시(파괴된 Actor 참조)를 버린다.</summary>
public static void ClearAll()
{
RestoreAll();
s_states.Clear();
}
/// <summary>프로브용: 이 몹이 지금 경직 중인가.</summary>
public static bool IsStaggered(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.activeIndex >= 0;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
StaggerCount = RefreshCount = ReleasedByCcCount = HoldCount = SkippedFilter = SkippedNoAgent = EndedCount = 0;
LastDuration = 0f;
}
}
}

View File

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

View File

@ -72,18 +72,24 @@ namespace WL.Combat.Reaction
// ─────────────────────────────────────────── 점멸 // ─────────────────────────────────────────── 점멸
[Header("점멸 (HitConfirmed → MPB on → blinkOnSeconds 뒤 off)")] [Header("점멸 (HitConfirmed → MPB on → blinkOnSeconds 뒤 off)")]
[Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.")] [Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.\n" +
[ColorUsage(true, true)] public Color blinkColor = new Color(2.2f, 2.2f, 2.2f, 1f); "PD #816zb — '짧게, 대신 세게'. 2.2 → 3.0.")]
[ColorUsage(true, true)] public Color blinkColor = new Color(3f, 3f, 3f, 1f);
[Tooltip("에미션 프로퍼티도 함께 올린다(MK Toon 은 키워드 없이 가산 · URP Lit 은 _EMISSION 키워드가 켜진 머티리얼만).")] [Tooltip("에미션 프로퍼티도 함께 올린다(MK Toon 은 키워드 없이 가산 · URP Lit 은 _EMISSION 키워드가 켜진 머티리얼만).")]
public bool useEmission = true; public bool useEmission = true;
[Tooltip("점멸 에미션 색(HDR).")] [Tooltip("점멸 에미션 색(HDR). PD #816zb — 0.6 → 0.9 (같은 시간에 더 세게).")]
[ColorUsage(true, true)] public Color blinkEmission = new Color(0.6f, 0.6f, 0.6f, 1f); [ColorUsage(true, true)] public Color blinkEmission = new Color(0.9f, 0.9f, 0.9f, 1f);
[Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")] [Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")]
public float blinkOnSeconds = 0.03f; public float blinkOnSeconds = 0.03f;
[Tooltip("한 번 켜진 점멸이 이어질 수 있는 **총 상한**(초 · unscaled · 0 이하면 상한 없음).\n" +
"PD #816zb — 몹이 계속 하얗게 보이던 원인은 ① 임팩트 티어 시간 배수(최대 2.4 → 0.072 s)와\n" +
"② 점멸 중 추가 타격마다 blinkOffAt 을 다시 미루는 연장 경로였다. 연장까지 포함해 첫 점멸 시작 시각 + 이 값에서 끊는다. 0.06 = 발주 규정값.")]
public float blinkMaxSeconds = 0.06f;
[Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")] [Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")]
public float blinkOffSeconds = 0.03f; public float blinkOffSeconds = 0.03f;

View File

@ -32,6 +32,7 @@ namespace WL.Combat.Reaction
KillChain.ClearAll(); KillChain.ClearAll();
DamageAggregator.ClearAll(); DamageAggregator.ClearAll();
MobHitFlash.ClearAll(); MobHitFlash.ClearAll();
MobStagger.ClearAll(); // WL #816zb — 씬을 넘기며 speed 0 인 몹이 남지 않게
} }
void Update() void Update()
@ -40,9 +41,10 @@ namespace WL.Combat.Reaction
KillChain.Tick(now); KillChain.Tick(now);
DamageAggregator.Tick(now); DamageAggregator.Tick(now);
MobHitFlash.Tick(now); MobHitFlash.Tick(now);
MobStagger.Tick(now); // WL #816zb — 피격 경직 해제
} }
void OnDisable() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } void OnDisable() { MobHitFlash.RestoreAll(); MobStagger.RestoreAll(); KillChain.ReleaseBurst(); }
void OnApplicationQuit() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } void OnApplicationQuit() { MobHitFlash.RestoreAll(); MobStagger.RestoreAll(); KillChain.ReleaseBurst(); }
} }
} }

View File

@ -17,10 +17,11 @@ MonoBehaviour:
targetMobsOnly: 1 targetMobsOnly: 1
onlyByMainPC: 0 onlyByMainPC: 0
skipWhenDamageZero: 1 skipWhenDamageZero: 1
blinkColor: {r: 2.2, g: 2.2, b: 2.2, a: 1} blinkColor: {r: 3, g: 3, b: 3, a: 1}
useEmission: 1 useEmission: 1
blinkEmission: {r: 0.6, g: 0.6, b: 0.6, a: 1} blinkEmission: {r: 0.9, g: 0.9, b: 0.9, a: 1}
blinkOnSeconds: 0.03 blinkOnSeconds: 0.03
blinkMaxSeconds: 0.06
blinkOffSeconds: 0.03 blinkOffSeconds: 0.03
colorProperties: colorProperties:
- _AlbedoColor - _AlbedoColor

View File

@ -37,4 +37,8 @@ MonoBehaviour:
flashColor: {r: 1, g: 1, b: 1, a: 0.1} flashColor: {r: 1, g: 1, b: 1, a: 0.1}
flashDuration: 0.06 flashDuration: 0.06
hapticEnabled: 0 hapticEnabled: 0
mobStaggerEnabled: 1
mobStaggerSeconds: 0.18
mobStaggerSecondsElite: 0.12
mobStaggerSecondsBoss: 0.08
logHits: 0 logHits: 0

View File

@ -141,6 +141,24 @@ public class WLHitFeelSettings : ScriptableObject
[Tooltip("NiceVibrations 햅틱. 모바일 배터리 때문에 기본 false.")] [Tooltip("NiceVibrations 햅틱. 모바일 배터리 때문에 기본 false.")]
public bool hapticEnabled = false; public bool hapticEnabled = false;
// ─────────────────────────────────────────── ⑥ 피격 몹 경직 (스태거)
[Header("⑥ 피격 몹 경직 — 맞은 몹이 아주 짧게 멈춘다")]
[Tooltip("PD #816zb — 맞은 몹이 넉백으로 밀린 직후 바로 걸어 나와 타격감이 죽는 문제.\n" +
"직접 타격(HitConfirmed) 마다 NavMeshAgent 속도를 0 으로 잠그고 애니메이터를 멈춘다(WL.Combat.Reaction.MobStagger).\n" +
"끄면 그 구독자가 아무 것도 하지 않는다 = 기존 동작 100 %. 롤백 스위치.")]
public bool mobStaggerEnabled = true;
[Tooltip("일반 몹 경직 길이(초 · unscaled). 콤보 간격 0.42~0.47 s 의 절반 이하로 둔다 — 0.18 = 발주 규정값.\n" +
"넉백 중에 맞으면 넉백이 끝난 시점부터 이 길이를 1회 다시 준다(밀리다 말고 곧장 걷지 않게).")]
public float mobStaggerSeconds = 0.18f;
[Tooltip("엘리트 경직 길이(초). 일반보다 짧게 — 압박감을 유지한다.")]
public float mobStaggerSecondsElite = 0.12f;
[Tooltip("보스 경직 길이(초). 가장 짧게 — 보스 패턴이 끊기면 안 된다.")]
public float mobStaggerSecondsBoss = 0.08f;
// ─────────────────────────────────────────── 진단 // ─────────────────────────────────────────── 진단
[Header("진단 (검증용 · 배포 시 false)")] [Header("진단 (검증용 · 배포 시 false)")]