[WL-815f] 공격 예고 1차: 지면 데칼(판정 일치) · 선딜 속도 곡선 · 펄스/색 규약 · SFX · 히트스톱 조건 (#815)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-11 01:03:37 +09:00
parent fc88c7d30a
commit ca6e102d3a
15 changed files with 2322 additions and 0 deletions

View File

@ -0,0 +1,698 @@
// WL-815f 프로브 — 적 공격 예고(텔레그래프) 1차 · 에디트 모드 전용(Play 0 · 로그인 0 · 커밋 0)
//
// MakeAsset — WLTelegraphSettings.asset 생성/갱신(손으로 YAML 쓰지 않는다)
// Run — A 클립 히트 시각 표(몹 12종) · B 콜라이더 실측 · C 가짜 왕복(3종+보스) · D 동시 상한
// E 보스 무변경 · F GC 0 · G C8 · H 애니메이터 실측(데미지도 늦어짐 증명) · I 캡처 3장
//
// 산출물 = AgentScripts/staging/WL815f/EDIT_PROBE.txt · 캡처 = Screenshots_WL/WL815f/
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using WL.Combat.Telegraph;
public static class WL815f_Probe
{
const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL815f";
const string kSnap = "AgentScripts/staging/WL815f/EDIT_PROBE.txt";
const string kSo = "Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset";
const string kMobRoot = "Assets/Res_Addr/Mobs/";
const string kProjRoot = "Assets/Res_Addr/Projectile/";
const int W = 1080, H = 1920;
static System.Text.StringBuilder sb;
static int pass, fail;
static readonly List<string> s_errors = new List<string>();
static void L(string s) { sb.Append(s).Append('\n'); }
static void Chk(bool ok, string label, string detail)
{
if (ok) pass++; else fail++;
sb.Append(ok ? " PASS " : " FAIL ").Append(label);
if (!string.IsNullOrEmpty(detail)) sb.Append(" ").Append(detail);
sb.Append('\n');
}
static string F(float v, int d) { return v.ToString(d == 3 ? "F3" : d == 2 ? "F2" : "F4"); }
// ─────────────────────────────────────────────────────────────────────────
public static string MakeAsset()
{
sb = new System.Text.StringBuilder();
var dir = System.IO.Path.GetDirectoryName(kSo).Replace('\\', '/');
if (!System.IO.Directory.Exists(dir)) { L("FAIL 폴더 없음 " + dir); return sb.ToString(); }
var so = AssetDatabase.LoadAssetAtPath<WLTelegraphSettings>(kSo);
bool created = false;
if (so == null)
{
so = ScriptableObject.CreateInstance<WLTelegraphSettings>();
AssetDatabase.CreateAsset(so, kSo);
created = true;
}
// 캡처 실측으로 조정한 값(몹이 통째로 노랗게 보여 에미션을 낮췄다) — 코드 기본값과 같게 유지한다
so.tintAlbedoAvoidable = new Color(1.20f, 1.05f, 0.60f, 1f);
so.tintEmissionAvoidable = new Color(0.55f, 0.34f, 0.02f, 1f);
so.tintAlbedoUnavoidable = new Color(1.30f, 0.60f, 0.55f, 1f);
so.tintEmissionUnavoidable = new Color(0.75f, 0.08f, 0.04f, 1f);
so.defaultProjectileSpeed = 5f;
EditorUtility.SetDirty(so);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
L((created ? "CREATED " : "EXISTS ") + kSo);
L(" enabled=" + so.enabled + " 잡몹 " + so.telegraphSecondsNormal + "s · 원거리 " + so.telegraphSecondsRanged
+ "s · 엘리트 " + so.telegraphSecondsElite + "s · 하한 k " + so.minSpeedMultiplier
+ " · 상한 " + so.maxConcurrent + " · 펄스 " + so.pulseScale + " · SFX idx " + so.sfxIndex);
return sb.ToString();
}
// ─────────────────────────────────────────────────────────────────────────
public static string Run()
{
sb = new System.Text.StringBuilder();
pass = fail = 0; s_errors.Clear();
Application.logMessageReceived += OnLog;
// 배치 에디터의 현재(제목 없는·저장 안 된) 씬을 그대로 작업장으로 쓴다.
// 만드는 오브젝트는 전부 HideFlags.DontSave 이고 끝에서 파기한다 → 저장 0 · 커밋 0.
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
try
{
L("# WL-815f 프로브 — 적 공격 예고 1차 (에디트 모드 · Play 0)");
L("생성 " + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
L("");
WLTelegraphSettings.ClearCache();
var st = WLTelegraphSettings.Instance;
if (st == null) { L("FAIL — SO 를 못 읽었다: " + kSo); return Finish(); }
Telegraph.EnsureSubscribed();
Telegraph.ResetDiagnostics();
Telegraph.ClearClipCache();
TelegraphShape.ClearCache(); TelegraphShape.ResetDiagnostics();
TelegraphDecal.ResetDiagnostics();
WL.Combat.Reaction.ImpactTier.ResetDiagnostics();
TelegraphRunner.BeginForceClock();
SectionA(st);
SectionB(st);
SectionC(st, scene);
SectionD(st, scene);
SectionE(st, scene);
SectionF(st, scene);
SectionG(st, scene);
SectionH(scene);
SectionJ(st, scene);
SectionI(st, scene);
}
catch (System.Exception ex)
{
L("EXCEPTION " + ex.GetType().Name + " : " + ex.Message);
L(ex.StackTrace);
fail++;
}
finally
{
Telegraph.ClearAll();
TelegraphDecal.DestroyAll();
TelegraphRunner.ResetForceClock();
WLTelegraphSettings.RuntimeDisabled = false;
CleanupStrays();
Application.logMessageReceived -= OnLog;
}
return Finish();
}
static string Finish()
{
L("");
L("## 콘솔 Error/Exception " + s_errors.Count + "건");
for (int i = 0; i < s_errors.Count && i < 12; i++) L(" " + s_errors[i]);
L("");
L("RESULT PASS " + pass + " / FAIL " + fail);
var dir = System.IO.Path.GetDirectoryName(kSnap);
if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(kSnap, sb.ToString());
return sb.ToString();
}
/// <summary>프로브가 만든 오브젝트가 씬에 남지 않게 한다(이름 접두 PROBE_ · 저장 0).</summary>
static void CleanupStrays()
{
var all = Object.FindObjectsByType<GameObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
int n = 0;
for (int i = 0; i < all.Length; i++)
{
var g = all[i];
if (g == null || g.transform.parent != null) continue;
if (g.name.StartsWith("PROBE_") || g.name.StartsWith("__WLTelegraph")) { Object.DestroyImmediate(g); n++; }
}
if (n > 0) L(" (정리) 프로브 오브젝트 " + n + "개 파기");
}
static void OnLog(string cond, string stack, LogType t)
{
if (t == LogType.Error || t == LogType.Exception || t == LogType.Assert)
if (s_errors.Count < 40) s_errors.Add(t + " : " + cond);
}
// ── 몹 표(기준서 §G-0 실측표와 대조할 12종) ────────────────────────────
struct MobRow { public string label, prefab, proj; public float lifetime; public bool elite, boss; }
static readonly MobRow[] s_mobs =
{
new MobRow{ label="Batty_A(101) 존1 잡몹", prefab="Mob/Batty_A", proj="Mob/P_Batty_Meele", lifetime=0.1f },
new MobRow{ label="Batty_B(102) 존1 원거리", prefab="Mob/Batty_B", proj="Mob/P_Batty_Range", lifetime=1.5f },
new MobRow{ label="Porin_A(168) 존2 잡몹", prefab="Mob/Porin_A", proj="Mob/P_Porin_Meele", lifetime=0.1f },
new MobRow{ label="Racco_A(173) 존3 잡몹", prefab="Mob/Racco_A", proj="Mob/P_Racco_Meele", lifetime=0.1f },
new MobRow{ label="Rabby_Brown(146) 존3 잡몹", prefab="Mob/Rabby_Brown", proj="Mob/P_Rabby_Meele", lifetime=0.1f },
new MobRow{ label="Devilu_A(118) 존4 잡몹", prefab="Mob/Devilu_A", proj="Mob/P_Devilu_Meele", lifetime=0.1f },
new MobRow{ label="Golem_A(1014) 엘리트", prefab="Elite/Golem_A", proj="Elite/P_Golem_A", lifetime=0.1f, elite=true },
new MobRow{ label="Aspethis_D(1023) 엘리트", prefab="Elite/Aspethis_D", proj="Elite/P_Aspethis_D", lifetime=0.1f, elite=true },
new MobRow{ label="Beeto_A(1009) 엘리트", prefab="Elite/Beeto_A", proj="Elite/P_Beeto_A", lifetime=0.1f, elite=true },
new MobRow{ label="Cute_Crab_C(1028) 엘리트", prefab="Elite/Cute_Crab_C_Blue", proj="Elite/P_Cute_Crab_C_Blue",lifetime=0.1f, elite=true },
new MobRow{ label="Mummy_King(1024) 엘리트", prefab="Elite/Mummy_King", proj="Elite/P_Mummy_King", lifetime=1.5f, elite=true },
new MobRow{ label="Anubis(10006) 보스", prefab="FieldBoss/Anubis", proj="FieldBoss/Anubis_Attack", lifetime=0.1f, boss=true },
};
// ─────────────────────────────────────────────────────────── A. 클립 히트 시각 표
static void SectionA(WLTelegraphSettings st)
{
L("## A. 예고 시간 전/후 표 (몹 12종 · 클립 히트 이벤트 실측 · 목표 = SO 값)");
L("| 몹 | 컨트롤러 | 히트 이벤트 s(전) | 등급 목표 s | k(= 적용 속도) | 하한 걸림 | 예고 s(후) | 배수 |");
L("|---|---|---|---|---|---|---|---|");
int measured = 0;
for (int i = 0; i < s_mobs.Length; i++)
{
var m = s_mobs[i];
var go = AssetDatabase.LoadAssetAtPath<GameObject>(kMobRoot + m.prefab + ".prefab");
if (go == null) { L("| " + m.label + " | (프리팹 없음) | | | | | | |"); continue; }
var anim = go.GetComponentInChildren<Animator>(true);
var rac = anim != null ? anim.runtimeAnimatorController : null;
string racName = rac != null ? rac.name : "(없음)";
var tbl = Telegraph.HitSecondsTable(rac);
float before = tbl != null && tbl.Length > 0 ? tbl[0] : -1f;
if (before > 0f) measured++;
bool ranged = m.lifetime >= st.rangedLifetimeThreshold;
float target = m.boss ? -1f : (m.elite ? st.telegraphSecondsElite : (ranged ? st.telegraphSecondsRanged : st.telegraphSecondsNormal));
if (m.boss)
{
L("| " + m.label + " | " + racName + " | " + (before > 0f ? F(before, 3) : "-")
+ " | **무변경**(코드 3.0 s 차징) | 1.000 | - | " + (before > 0f ? F(before, 3) : "-") + " | ×1.00 |");
continue;
}
if (before <= 0f) { L("| " + m.label + " | " + racName + " | (히트 이벤트 못 찾음) | " + F(target, 2) + " | | | | |"); continue; }
float desired = before / target;
float floorSpeed = 1f * Mathf.Clamp(st.minSpeedMultiplier, 0.01f, 1f);
float applied = Mathf.Clamp(desired, floorSpeed, 1f);
bool clamped = desired < floorSpeed - 0.0001f;
float after = before / applied;
L("| " + m.label + " | " + racName + " | " + F(before, 3) + " | " + F(target, 2) + " | " + F(applied, 3)
+ " | " + (clamped ? "🔴 O" : "-") + " | **" + F(after, 3) + "** | ×" + F(after / before, 2) + " |");
}
L("");
Chk(measured >= 10, "12종 중 클립 히트 이벤트 실측 성공", measured + " / " + s_mobs.Length);
L("→ 🔴 속도 곡선만으로는 목표 0.5/0.6/0.8 s 에 도달하지 못한다(하한 k=" + st.minSpeedMultiplier + ").");
L(" 나머지 시간은 **바닥 판이 채워지는 " + F(st.telegraphSecondsNormal, 2) + " s** 와 몹 크기/색/소리가 채운다(기준서 §G-2 ⓐ 한계 그대로).");
L("");
}
// ─────────────────────────────────────────────────────────── B. 콜라이더 실측
static void SectionB(WLTelegraphSettings st)
{
L("## B. 데칼 크기의 출처 = 투사체 프리팹 BoxCollider (신규 데이터 0)");
L("| 투사체 | 읽은 size | 읽은 center | 속도 | 출처 | 기준서 §G-0 실측 |");
L("|---|---|---|---|---|---|");
string[] expect = { "3×3×3 c(0,0,0)", "1×3×1 c(0,0,0)", "3×3×3 c(0,0,0)", "3×3×3 c(0,0,1.5)", "2×2×4 c(0,0,2)" };
string[] names = { "Mob/P_Batty_Meele", "Mob/P_Batty_Range", "Mob/P_Porin_Meele", "Elite/P_Golem_A", "FieldBoss/Anubis_Attack" };
Vector3[] wantSize = { new Vector3(3, 3, 3), new Vector3(1, 3, 1), new Vector3(3, 3, 3), new Vector3(3, 3, 3), new Vector3(2, 2, 4) };
Vector3[] wantCenter = { Vector3.zero, Vector3.zero, Vector3.zero, new Vector3(0, 0, 1.5f), new Vector3(0, 0, 2f) };
int ok = 0;
for (int i = 0; i < names.Length; i++)
{
var b = TelegraphShape.Get(names[i]);
bool match = b.resolved && (b.size - wantSize[i]).sqrMagnitude < 0.0001f && (b.center - wantCenter[i]).sqrMagnitude < 0.0001f;
if (match) ok++;
L("| " + names[i] + " | " + b.size + " | " + b.center + " | " + F(b.speed, 2) + " | " + b.source + " | " + expect[i] + (match ? " ✅" : " ❌") + " |");
}
Chk(ok == names.Length, "콜라이더 실측 = 기준서 §G-0 표와 완전 일치", ok + " / " + names.Length);
L("");
}
// ─────────────────────────────────────────────────────────── C. 가짜 왕복
static GameObject MakeMob(MobRow m, Vector3 pos, UnityEngine.SceneManagement.Scene scene, out MobActor actor)
{
actor = null;
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(kMobRoot + m.prefab + ".prefab");
if (prefab == null) return null;
var go = Object.Instantiate(prefab);
go.name = "PROBE_" + m.prefab.Replace('/', '_');
go.hideFlags = HideFlags.DontSave;
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, scene);
go.transform.position = pos;
go.transform.rotation = Quaternion.identity;
actor = go.GetComponent<MobActor>();
if (actor == null) { Object.DestroyImmediate(go); return null; }
actor.m_Role = eRole.Mob;
actor.m_SubRole = m.boss ? eSubRol.Boss : (m.elite ? eSubRol.Elite : eSubRol.None);
actor.m_animation = go.GetComponentInChildren<Animator>(true);
var td = new MonsterTableData();
td.e_MonsterType = actor.m_SubRole;
td.s_Porjectile1 = m.proj;
td.f_ProjectileLifeTime1 = m.lifetime;
td.e_AttackType = eAttackType.Physics;
td.f_DefaultScale = go.transform.localScale.x;
var fi = typeof(MobActor).GetField("m_MonsterTableData", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (fi != null) fi.SetValue(actor, td);
return go;
}
static void SectionC(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## C. 가짜 AttackStarted → HitboxSpawned 왕복 (몹 3종 + 보스 · 실측)");
int[] pick = { 0, 2, 6, 11 }; // Batty_A · Porin_A · Golem_A(엘리트) · Anubis(보스)
for (int p = 0; p < pick.Length; p++)
{
var m = s_mobs[pick[p]];
MobActor actor;
var go = MakeMob(m, new Vector3(p * 20f, 0f, 0f), scene, out actor);
if (go == null) { Chk(false, "C 몹 인스턴스 " + m.label, "프리팹/MobActor 없음"); continue; }
float scale0 = go.transform.localScale.x;
float speed0 = actor.m_animation != null ? 1f : -1f;
if (actor.m_animation != null) actor.m_animation.speed = 1f;
Telegraph.ResetDiagnostics();
TelegraphDecal.ResetDiagnostics();
WL.Combat.Core.CombatEvents.RaiseAttackStarted(actor, 0, 1f, null);
L("");
L("### " + m.label);
if (m.boss)
{
bool untouched = Telegraph.AppliedSpeedOf(actor) < 0f && (actor.m_animation == null || Mathf.Abs(actor.m_animation.speed - 1f) < 0.0001f)
&& Mathf.Abs(go.transform.localScale.x - scale0) < 0.0001f && Telegraph.DecalIndexOf(actor) < 0;
Chk(untouched, "보스 무변경(속도·크기·판 전부 원본)",
"speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-") + " scale=" + F(go.transform.localScale.x, 3)
+ " 판=" + Telegraph.DecalIndexOf(actor) + " SkippedBoss=" + Telegraph.SkippedBoss);
Object.DestroyImmediate(go);
continue;
}
float planned = Telegraph.PlannedSecondsOf(actor);
float applied = Telegraph.AppliedSpeedOf(actor);
int decal = Telegraph.DecalIndexOf(actor);
L(" 히트 시각 " + F(Telegraph.LastHitSeconds, 3) + " s → 목표 " + F(Telegraph.LastTargetSeconds, 2)
+ " s · 속도 " + F(Telegraph.LastOrigSpeed, 3) + "→" + F(applied, 3)
+ (Telegraph.LastWasClamped ? " (🔴 하한 " + st.minSpeedMultiplier + " 로 잘림)" : "")
+ " · 실제 예고 " + F(planned, 3) + " s");
Chk(applied > 0f && actor.m_animation != null && Mathf.Abs(actor.m_animation.speed - applied) < 0.0001f,
"animator.speed 가 실제로 낮아졌다", "anim.speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-"));
Chk(Mathf.Abs(applied - Mathf.Max(Telegraph.LastHitSeconds / Telegraph.LastTargetSeconds, st.minSpeedMultiplier)) < 0.001f,
"속도 = max(히트시각 ÷ 목표, 하한)", "기대 " + F(Mathf.Max(Telegraph.LastHitSeconds / Telegraph.LastTargetSeconds, st.minSpeedMultiplier), 3));
// 판 크기 = 콜라이더 크기인가
Vector3 psize, pcenter; float yaw;
var box = TelegraphShape.Get(m.proj);
bool ranged = m.lifetime >= st.rangedLifetimeThreshold;
if (TelegraphDecal.TryGetPlate(decal, out psize, out pcenter, out yaw))
{
float wantW = box.size.x, wantL = ranged ? Mathf.Clamp(box.speed * m.lifetime, box.size.z, st.rangedMaxLength) : box.size.z;
bool sizeOk = Mathf.Abs(psize.x - wantW) < 0.01f && Mathf.Abs(psize.z - wantL) < 0.01f;
Chk(sizeOk, "판 크기 = 투사체 콜라이더 크기", "판 " + F(psize.x, 3) + "×" + F(psize.z, 3) + " · 콜라이더 " + F(wantW, 3) + "×" + F(wantL, 3));
float wantZ = st.forwardOffset + box.center.z + (ranged ? wantL * 0.5f : 0f);
bool cenOk = Mathf.Abs((pcenter.z - go.transform.position.z) - wantZ) < 0.01f;
Chk(cenOk, "판 중심 = 발사 위치 + collider.center", "판 z=" + F(pcenter.z - go.transform.position.z, 3) + " 기대 " + F(wantZ, 3));
}
else Chk(false, "판이 켜졌다", "decalIdx=" + decal);
// 회전 추종
go.transform.rotation = Quaternion.Euler(0f, 90f, 0f);
TelegraphRunner.ForceTick(0.02f);
if (TelegraphDecal.TryGetPlate(decal, out psize, out pcenter, out yaw))
Chk(Mathf.Abs(Mathf.DeltaAngle(yaw, 90f)) < 0.5f, "몹이 돌면 판도 따라 돈다", "판 yaw=" + F(yaw, 2));
// 채워지는 진행 + 펄스
float half = Mathf.Max(0.02f, planned * 0.5f - 0.02f);
TelegraphRunner.ForceTick(half);
float midScale = go.transform.localScale.x;
Chk(midScale > scale0 * 1.0005f && midScale < scale0 * st.pulseScale, "예고 중 크기가 커진다(펄스)",
"기본 " + F(scale0, 3) + " → 중간 " + F(midScale, 3) + " (상한 " + F(scale0 * st.pulseScale, 3) + ")");
Chk(WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor) || !st.tintEnabled, "예고 중 몹 색(MPB)이 걸렸다",
"TintApplied=" + Telegraph.TintApplied);
// 811gh 히트스톱 스킵 조건
WL.Combat.Reaction.ImpactTier.ClearStamp();
var tier = WL.Combat.Reaction.ImpactTier.Resolve(actor, null, false);
float hs = WL.Combat.Reaction.ImpactTier.HitStopSeconds(tier);
Chk(!WL.Combat.Reaction.ImpactTier.Active || hs <= 0.0001f, "예고 중 히트스톱 = 0 (기준서 §G-4)",
"tier=" + tier + " hitStop=" + F(hs, 4) + "s skip=" + WL.Combat.Reaction.ImpactTier.LastSkipHitStop
+ " (티어 활성=" + WL.Combat.Reaction.ImpactTier.Active + ")");
// 히트 → 원복
WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actor, m.proj, 1f, m.lifetime, go.transform.position);
Chk(actor.m_animation != null && Mathf.Abs(actor.m_animation.speed - 1f) < 0.0001f, "히트에서 animator.speed 원복",
"speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-") + " (원본 " + F(speed0, 3) + ")");
Chk(Mathf.Abs(go.transform.localScale.x - scale0) < 0.0001f, "히트에서 크기 원복",
"scale=" + F(go.transform.localScale.x, 4) + " 기본 " + F(scale0, 4));
Chk(TelegraphDecal.InUse == 0, "판 반납", "InUse=" + TelegraphDecal.InUse);
// 흰 섬광 만료 후 색 원복
TelegraphRunner.ForceTick(st.flashSeconds + 0.02f);
TelegraphRunner.ForceTick(st.pulseRestoreGuardSeconds + 0.02f);
Chk(!WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor), "섬광 뒤 몹 색 원복(MPB 잔류 0)",
"HasTint=" + WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor) + " HasBlock=" + WL.Combat.Reaction.MobHitFlash.HasAnyBlock(actor));
Chk(Telegraph.SpeedRestored >= 1 && Telegraph.PulseRestored >= 1, "원복 카운터",
"SpeedRestored=" + Telegraph.SpeedRestored + " PulseRestored=" + Telegraph.PulseRestored);
Object.DestroyImmediate(go);
}
L("");
}
// ─────────────────────────────────────────────────────────── D. 동시 상한
static void SectionD(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## D. 동시 상한 " + st.maxConcurrent + " (초과 = 판 생략 · 811s 예산기 방식)");
Telegraph.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics(); Telegraph.ClearAll();
int n = st.maxConcurrent + 3;
var gos = new GameObject[n];
var actors = new MobActor[n];
for (int i = 0; i < n; i++) gos[i] = MakeMob(s_mobs[0], new Vector3(i * 6f, 0f, 0f), scene, out actors[i]);
for (int i = 0; i < n; i++)
if (actors[i] != null) WL.Combat.Core.CombatEvents.RaiseAttackStarted(actors[i], 0, 1f, null);
Chk(TelegraphDecal.InUse == st.maxConcurrent, "동시에 켜진 판 = 상한", "InUse=" + TelegraphDecal.InUse + " / 상한 " + st.maxConcurrent);
Chk(TelegraphDecal.BudgetSkipCount == n - st.maxConcurrent, "초과분은 판 생략", "생략 " + TelegraphDecal.BudgetSkipCount + " / 기대 " + (n - st.maxConcurrent));
int granted = Telegraph.SfxPlayed + Telegraph.SfxNoManager;
Chk(granted == st.sfxMaxConcurrent && Telegraph.SfxThrottled == n - st.sfxMaxConcurrent,
"예고 SFX 동시 상한 " + st.sfxMaxConcurrent + " (기준서 §G-4)",
"토큰 " + granted + " · 스킵 " + Telegraph.SfxThrottled + " / " + n + "회 요청 · 실재생 " + Telegraph.SfxPlayed
+ " (에디트 모드라 SoundInfo 인스턴스=" + SoundInfo.isIns + " → 실재생은 「미확인」)");
L(" 풀 크기 " + TelegraphDecal.PoolSize + " · 풀 성장 " + TelegraphDecal.PoolGrowCount + "회 · 셰이더 " + TelegraphDecal.ShaderName);
// 전부 반납
for (int i = 0; i < n; i++) if (actors[i] != null) WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actors[i], s_mobs[0].proj, 1f, 0.1f, Vector3.zero);
Chk(TelegraphDecal.InUse == 0, "전부 반납(풀 누수 0)", "InUse=" + TelegraphDecal.InUse);
for (int i = 0; i < n; i++) if (gos[i] != null) Object.DestroyImmediate(gos[i]);
Telegraph.ClearAll();
L("");
}
// ─────────────────────────────────────────────────────────── E. 보스 + 타임아웃
static void SectionE(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## E. 보스 무변경 · 히트가 안 올 때 강제 원복");
Telegraph.ResetDiagnostics(); Telegraph.ClearAll();
MobActor boss;
var bgo = MakeMob(s_mobs[11], new Vector3(-30f, 0f, 0f), scene, out boss);
if (bgo != null)
{
float s0 = bgo.transform.localScale.x;
if (boss.m_animation != null) boss.m_animation.speed = 1f;
for (int i = 0; i < 3; i++) WL.Combat.Core.CombatEvents.RaiseAttackStarted(boss, 0, 1f, null);
Chk(Telegraph.SkippedBoss == 3 && Telegraph.StartCount == 0, "보스 AttackStarted 3회 → 예고 0",
"SkippedBoss=" + Telegraph.SkippedBoss + " Start=" + Telegraph.StartCount);
Chk(boss.m_animation == null || Mathf.Abs(boss.m_animation.speed - 1f) < 0.0001f, "보스 animator.speed 무변경",
"speed=" + (boss.m_animation != null ? F(boss.m_animation.speed, 3) : "-"));
Chk(Mathf.Abs(bgo.transform.localScale.x - s0) < 0.0001f, "보스 크기 무변경", "scale=" + F(bgo.transform.localScale.x, 4));
Object.DestroyImmediate(bgo);
}
Telegraph.ResetDiagnostics();
MobActor a;
var go = MakeMob(s_mobs[0], new Vector3(-60f, 0f, 0f), scene, out a);
if (go != null)
{
float s0 = go.transform.localScale.x;
if (a.m_animation != null) a.m_animation.speed = 1f;
WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null);
TelegraphRunner.ForceTick(st.maxTelegraphSeconds + 0.1f); // 히트가 안 온다(피격·사망)
Chk(Telegraph.TimeoutCount == 1, "타임아웃 감지", "Timeout=" + Telegraph.TimeoutCount);
Chk(a.m_animation != null && Mathf.Abs(a.m_animation.speed - 1f) < 0.0001f, "타임아웃 → 속도 원복", "speed=" + F(a.m_animation.speed, 3));
TelegraphRunner.ForceTick(st.pulseRestoreGuardSeconds + 0.05f);
Chk(Mathf.Abs(go.transform.localScale.x - s0) < 0.0001f, "타임아웃 → 크기 원복", "scale=" + F(go.transform.localScale.x, 4));
Chk(TelegraphDecal.InUse == 0, "타임아웃 → 판 반납", "InUse=" + TelegraphDecal.InUse);
Object.DestroyImmediate(go);
}
Telegraph.ClearAll();
L("");
}
// ─────────────────────────────────────────────────────────── F. GC
static void SectionF(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## F. GC (워밍 후 차분 · GC.GetAllocatedBytesForCurrentThread)");
Telegraph.ClearAll(); Telegraph.ResetDiagnostics();
MobActor a;
var go = MakeMob(s_mobs[0], new Vector3(-90f, 0f, 0f), scene, out a);
if (go == null) { Chk(false, "F 몹 인스턴스", "없음"); return; }
if (a.m_animation != null) a.m_animation.speed = 1f;
for (int i = 0; i < 30; i++) // 워밍(캐시·풀·머티리얼)
{
WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null);
TelegraphRunner.ForceTick(0.02f);
WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero);
TelegraphRunner.ForceTick(0.3f);
}
long b0 = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 200; i++)
{
WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null);
TelegraphRunner.ForceTick(0.02f);
WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero);
TelegraphRunner.ForceTick(0.3f);
}
long d1 = System.GC.GetAllocatedBytesForCurrentThread() - b0;
long b1 = System.GC.GetAllocatedBytesForCurrentThread();
WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null);
for (int i = 0; i < 5000; i++) TelegraphRunner.ForceTick(0.0001f);
long d2 = System.GC.GetAllocatedBytesForCurrentThread() - b1;
WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero);
Chk(d1 == 0, "왕복 ×200 할당 0 B", d1 + " B");
Chk(d2 == 0, "틱 ×5000 할당 0 B", d2 + " B");
Object.DestroyImmediate(go);
Telegraph.ClearAll();
L("");
}
// ─────────────────────────────────────────────────────────── G. C8
static void SectionG(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## G. C8 — enabled = 0 이면 전부 원본");
Telegraph.ClearAll(); Telegraph.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics();
WL.Combat.Reaction.ImpactTier.ResetDiagnostics();
WLTelegraphSettings.RuntimeDisabled = true; // 에셋 enabled = 0 과 같은 게이트
MobActor a;
var go = MakeMob(s_mobs[0], new Vector3(-120f, 0f, 0f), scene, out a);
if (go != null)
{
float s0 = go.transform.localScale.x;
if (a.m_animation != null) a.m_animation.speed = 1f;
WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null);
TelegraphRunner.ForceTick(0.2f);
Chk(Telegraph.StartCount == 0 && Telegraph.SkippedDisabled >= 1, "예고 0", "Start=" + Telegraph.StartCount + " SkippedDisabled=" + Telegraph.SkippedDisabled);
Chk(a.m_animation != null && Mathf.Abs(a.m_animation.speed - 1f) < 0.0001f, "animator.speed 원본", "speed=" + F(a.m_animation.speed, 3));
Chk(Mathf.Abs(go.transform.localScale.x - s0) < 0.0001f, "크기 원본", "scale=" + F(go.transform.localScale.x, 4));
Chk(TelegraphDecal.InUse == 0 && !WL.Combat.Reaction.MobHitFlash.HasEliteTint(a), "판 0 · MPB 0", "InUse=" + TelegraphDecal.InUse);
Chk(!WLTelegraphSettings.SkipHitStop, "히트스톱 스킵 조건도 꺼진다(811gh 기존 동작)", "SkipHitStop=" + WLTelegraphSettings.SkipHitStop);
Object.DestroyImmediate(go);
}
WLTelegraphSettings.RuntimeDisabled = false;
Telegraph.ClearAll();
L("");
}
// ─────────────────────────────────────────────────────────── H. 애니메이터 실측
static void SectionH(UnityEngine.SceneManagement.Scene scene)
{
L("## H. 🔴 「데미지도 같이 늦어진다」 실측 — animator.speed 를 낮추면 클립 진행이 정확히 반비례한다");
L(" (히트 = 클립 안의 애니메이션 이벤트 `Projectile` 이므로 클립이 늦어지면 데미지도 늦어진다)");
L("| 몹 | 속도 | 0.2 s 동안 normalizedTime 진행 | 기대(= 0.2 ÷ 길이 × 속도) | 오차 |");
L("|---|---|---|---|---|");
int[] pick = { 0, 2, 6 };
int okCount = 0;
for (int p = 0; p < pick.Length; p++)
{
var m = s_mobs[pick[p]];
MobActor actor;
var go = MakeMob(m, new Vector3(p * 20f, 0f, 40f), scene, out actor);
if (go == null || actor.m_animation == null) { if (go != null) Object.DestroyImmediate(go); continue; }
var anim = actor.m_animation;
anim.enabled = true;
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
float len = ClipLength(anim, "attack");
for (int k = 0; k < 2; k++)
{
float sp = k == 0 ? 1f : 0.35f;
anim.speed = 1f;
anim.Play("attack", 0, 0f);
anim.Update(0f);
anim.speed = sp;
for (int i = 0; i < 20; i++) anim.Update(0.01f); // 0.2 s
float nt = anim.GetCurrentAnimatorStateInfo(0).normalizedTime;
float want = len > 0f ? 0.2f / len * sp : -1f;
float err = want > 0f ? Mathf.Abs(nt - want) : -1f;
bool ok = want > 0f && err < 0.02f;
if (ok) okCount++;
L("| " + m.label + " | " + F(sp, 2) + " | " + F(nt, 4) + " | " + F(want, 4) + " | " + (err >= 0f ? F(err, 4) : "-") + (ok ? " ✅" : " ❌") + " |");
}
anim.speed = 1f;
Object.DestroyImmediate(go);
}
Chk(okCount >= 4, "속도 ↔ 클립 진행 반비례 실측", okCount + " / " + (pick.Length * 2));
L("");
}
static float ClipLength(Animator anim, string state)
{
var rac = anim.runtimeAnimatorController;
if (rac == null) return -1f;
anim.Play(state, 0, 0f);
anim.Update(0f);
var infos = anim.GetCurrentAnimatorClipInfo(0);
if (infos != null && infos.Length > 0 && infos[0].clip != null) return infos[0].clip.length;
return -1f;
}
// ─────────────────────────────────────────────────────────── J. 보유 에셋 실측(SFX 클립 · 인디케이터 20종)
static void SectionJ(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## J. ④ 예고 SFX 클립 · ① IndicatorInfo 재사용 경로 (보유 에셋 실측)");
var sound = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/ResWork/UIPrefabs/Title/SoundInfo.prefab");
var si = sound != null ? sound.GetComponent<SoundInfo>() : null;
if (si != null && si.arr_clip != null)
{
bool inRange = st.sfxIndex >= 0 && st.sfxIndex < si.arr_clip.Length;
var clip = inRange ? si.arr_clip[st.sfxIndex] : null;
L(" SoundInfo.arr_clip 길이 " + si.arr_clip.Length + " · eSound.Max = " + (int)eSound.Max);
Chk(clip != null, "예고 SFX 클립 실존 (index " + st.sfxIndex + " = " + (eSound)st.sfxIndex + ")",
clip != null ? clip.name + " · " + F(clip.length, 3) + " s" : "null");
}
else Chk(false, "SoundInfo 프리팹/클립 배열", sound == null ? "프리팹 없음" : "arr_clip null");
// ① Show_Indicator 재사용 — 원본 공개 API(Make_Indicator) 경로가 살아 있는지
int indicators = 0;
var guids = AssetDatabase.FindAssets("_Indicator t:GameObject", new[] { "Assets/Res_Addr/Obj" });
if (guids != null) indicators = guids.Length;
L(" Res_Addr/Obj 인디케이터 프리팹 " + indicators + "종 (기준서 §G-1 = 20종)");
Chk(indicators >= 20, "보유 인디케이터 20종 실존", indicators + "종");
string save = st.indicatorPrefabName;
st.indicatorPrefabName = "Golem_Child_Skill1_Indicator";
int before = TelegraphDecal.IndicatorMissCount;
MobActor ia;
var igo = MakeMob(s_mobs[0], new Vector3(-150f, 0f, 0f), scene, out ia);
var box = TelegraphShape.Get("Mob/P_Batty_Meele");
int idx = igo != null ? TelegraphDecal.Acquire(ia, in box, false, 0.1f, TelegraphDanger.Avoidable) : -1;
st.indicatorPrefabName = save;
Chk(TelegraphDecal.IndicatorMissCount > before || TelegraphDecal.IndicatorBorrowCount > 0,
"IndicatorInfo 재사용 경로가 안전하게 분기한다(에디트 모드 = 싱글턴 없음)",
"isIns=" + IndicatorInfo.isIns + " miss=" + TelegraphDecal.IndicatorMissCount + " borrow=" + TelegraphDecal.IndicatorBorrowCount
+ " → 실제 표시는 Play 전용 「미확인」");
if (idx >= 0) TelegraphDecal.Release(idx);
if (igo != null) Object.DestroyImmediate(igo);
L(" 🔴 기본값은 빈 문자열 = 인디케이터 덧칠 off. 보유 20종은 전부 **원형 파티클**이라 사각 콜라이더와 100 % 일치가 안 된다 →");
L(" 판정 일치는 런타임 쿼드가 담당하고, 덧칠이 필요하면 SO 한 줄(indicatorPrefabName)로 켠다.");
L("");
}
// ─────────────────────────────────────────────────────────── I. 캡처
static void SectionI(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene)
{
L("## I. 캡처 3장 (1080×1920)");
if (!System.IO.Directory.Exists(kOutDir)) System.IO.Directory.CreateDirectory(kOutDir);
Telegraph.ClearAll(); Telegraph.ResetDiagnostics();
// 조명 + 지면(판이 보이게)
var lightGo = new GameObject("PROBE_Light"); lightGo.hideFlags = HideFlags.DontSave;
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(lightGo, scene);
var light = lightGo.AddComponent<Light>(); light.type = LightType.Directional; light.intensity = 1.1f;
lightGo.transform.rotation = Quaternion.Euler(45f, -30f, 0f);
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
ground.name = "PROBE_Ground"; ground.hideFlags = HideFlags.DontSave;
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(ground, scene);
ground.transform.localScale = new Vector3(6f, 1f, 6f);
var camGo = new GameObject("PROBE_Cam"); camGo.hideFlags = HideFlags.DontSave;
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(camGo, scene);
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0.18f, 0.2f, 0.24f);
cam.fieldOfView = 60f; cam.nearClipPlane = 0.05f; cam.farClipPlane = 200f;
int[] pick = { 0, 6, 11 };
string[] tags = { "1_잡몹_예고중", "2_엘리트_예고중", "3_보스_무변경" };
for (int p = 0; p < pick.Length; p++)
{
var m = s_mobs[pick[p]];
MobActor actor;
var go = MakeMob(m, Vector3.zero, scene, out actor);
if (go == null) { L(" (프리팹 없음) " + m.label); continue; }
if (actor.m_animation != null) { actor.m_animation.speed = 1f; actor.m_animation.cullingMode = AnimatorCullingMode.AlwaysAnimate; }
WL.Combat.Core.CombatEvents.RaiseAttackStarted(actor, 0, 1f, null);
float planned = Telegraph.PlannedSecondsOf(actor);
if (planned > 0f) TelegraphRunner.ForceTick(planned * 0.6f); // 60 % 채워진 순간
else TelegraphRunner.ForceTick(0.02f);
// 판(3×3 또는 3×3+center 1.5)과 몹이 화면에 꽉 차게 — 세로 1080×1920 구도
Vector3 focus = new Vector3(0f, 0f, m.boss ? 0.6f : 1.0f);
float span = Mathf.Max(3.2f, go.transform.localScale.x * 1.8f);
cam.transform.position = focus + new Vector3(0f, span * 1.15f, -span * 1.45f);
cam.transform.LookAt(focus + new Vector3(0f, span * 0.18f, 0f));
string path = Shot(cam, tags[p]);
L(" " + tags[p] + " — " + m.label + " · 예고 " + F(planned, 3) + " s · 판 " + (Telegraph.DecalIndexOf(actor) >= 0 ? "O" : "X") + " → " + path);
WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actor, m.proj, 1f, m.lifetime, Vector3.zero);
TelegraphRunner.ForceTick(0.5f);
Object.DestroyImmediate(go);
}
Object.DestroyImmediate(ground); Object.DestroyImmediate(camGo); Object.DestroyImmediate(lightGo);
int n = System.IO.Directory.GetFiles(kOutDir, "*.png").Length;
Chk(n >= 3, "캡처 3장", n + "장 · " + kOutDir);
L("");
}
static string Shot(Camera cam, string tag)
{
var rt = new RenderTexture(W, H, 24, RenderTextureFormat.ARGB32);
cam.aspect = (float)W / H;
cam.targetTexture = rt;
cam.Render();
var prev = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(W, H, TextureFormat.RGB24, false);
tex.ReadPixels(new Rect(0, 0, W, H), 0, 0);
tex.Apply();
RenderTexture.active = prev;
cam.targetTexture = null;
var path = System.IO.Path.Combine(kOutDir, tag + ".png");
System.IO.File.WriteAllBytes(path, ImageConversion.EncodeToPNG(tex));
Object.DestroyImmediate(tex);
rt.Release(); Object.DestroyImmediate(rt);
return path;
}
}

View File

@ -54,6 +54,10 @@ namespace WL.Combat.Reaction
public static int LastTierIndex { get { return (int)LastTier; } }
public static readonly int[] TierCount = new int[4];
public static int DowngradedCount, ResolvedCount, StampHitCount;
// WL #815f — 예고 중 히트스톱 스킵(기준서 §G-4). s_skipHitStop 은 Resolve 가 매 타격 갱신한다.
static bool s_skipHitStop;
public static int TelegraphHitStopSkipped;
public static bool LastSkipHitStop { get { return s_skipHitStop; } }
public static float LastDamage, LastAverage;
public static bool LastCritical, LastSkill, LastKill, LastBoss;
@ -100,6 +104,9 @@ namespace WL.Combat.Reaction
public static ImpactTierKind Resolve(Actor victim, DamageInfo dinfo, bool killHint)
{
if (!Active || victim == null) return ImpactTierKind.Light;
// WL #815f(기준서 §G-4) — 예고 중인 몹을 때렸으면 이 타격의 히트스톱을 건너뛴다.
// 히트스톱이 예고 구간에 겹치면 timeScale 이 바뀌어 예고 길이가 흔들린다. SO 플래그로 끌 수 있다(기본 on).
s_skipHitStop = WL.Combat.Telegraph.WLTelegraphSettings.SkipHitStop && WL.Combat.Telegraph.Telegraph.IsTelegraphing(victim);
if (s_stampVictim == victim && s_stampFrame == Time.frameCount) { StampHitCount++; LastTier = s_stampTier; return s_stampTier; }
float dmg = dinfo != null ? (float)dinfo.Damage : 0f;
@ -214,6 +221,7 @@ namespace WL.Combat.Reaction
{
var st = St;
if (st == null) return 0f;
if (s_skipHitStop) { TelegraphHitStopSkipped++; return 0f; } // WL #815f — 직전 Resolve 가 「예고 중」으로 판정한 타격
float sec = Get(st.hitStopMs, (int)tier, 0f) * 0.001f;
return Mathf.Clamp(sec, 0f, Mathf.Max(0f, st.hitStopMaxSeconds));
}
@ -262,6 +270,7 @@ namespace WL.Combat.Reaction
LastCritical = LastSkill = LastKill = LastBoss = false;
s_sampleHead = s_sampleCount = 0; s_sampleSum = 0f;
s_stampVictim = null; s_stampFrame = -1;
TelegraphHitStopSkipped = 0; s_skipHitStop = false; // WL #815f
}
}
}

View File

@ -0,0 +1,78 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 26b2beddb0dd9fa498cecef15d5e4cdd, type: 3}
m_Name: WLTelegraphSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Telegraph.WLTelegraphSettings
enabled: 1
verboseLog: 0
telegraphSecondsNormal: 0.5
telegraphSecondsRanged: 0.6
telegraphSecondsElite: 0.8
bossUntouched: 1
speedCurveEnabled: 1
minSpeedMultiplier: 0.35
hitEventNames:
- Projectile
- BossProjectile2
- BossProjectile3
defaultHitSeconds: 0.16
correctFromLiveClip: 1
maxTelegraphSeconds: 2.5
decalEnabled: 1
maxConcurrent: 6
poolWarmCount: 8
groundYOffset: 0.06
forwardOffset: 0.3
sizeScale: 1
defaultBoxSize: {x: 3, y: 3, z: 3}
rangedLifetimeThreshold: 0.5
rangedMaxLength: 12
defaultProjectileSpeed: 5
followRotation: 1
fillMode: 0
plateAlpha: 0.28
fillAlpha: 0.62
decalShaderNames:
- Universal Render Pipeline/Unlit
- Unlit/Transparent
- Sprites/Default
decalColorProperties:
- _BaseColor
- _Color
- _TintColor
decalRenderQueue: 3050
indicatorPrefabName:
indicatorAutoScale: 1
colorAvoidable: {r: 1, g: 0.82, b: 0.15, a: 1}
colorUnavoidable: {r: 1, g: 0.16, b: 0.12, a: 1}
dangerNormalMelee: 0
dangerNormalRanged: 0
dangerElite: 0
tintEnabled: 1
tintAlbedoAvoidable: {r: 1.2, g: 1.05, b: 0.6, a: 1}
tintEmissionAvoidable: {r: 0.55, g: 0.34, b: 0.02, a: 1}
tintAlbedoUnavoidable: {r: 1.3, g: 0.6, b: 0.55, a: 1}
tintEmissionUnavoidable: {r: 0.75, g: 0.08, b: 0.04, a: 1}
flashEnabled: 1
flashLeadSeconds: 0.06
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}
pulseEnabled: 1
pulseScale: 1.12
pulseRestoreGuardSeconds: 0.2
sfxEnabled: 1
sfxIndex: 9
sfxVolume: 0.35
sfxMaxConcurrent: 3
sfxWindowSeconds: 0.35
skipHitStopDuringTelegraph: 1

View File

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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c85ac270a98d7324d97c35f7116a3d31
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,617 @@
// ─────────────────────────────────────────────────────────────────────────────
// Telegraph.cs — 적 공격 예고 1차(§G-7 1차 = ① 데칼 + ⓐ 속도 곡선 + ⓓ 펄스 + ③ 색 규약 + ④ SFX)
//
// PD 지시 #815-5 「적의 공격을 확실히 유저가 인지하고 피할 수 있는 매커니즘」 · 발주서 WL-815f
// SOT = 기준서 v1 §G-0~§G-4 · §G-7
//
// ■ 원본 수정 0줄 — 이미 있는 배선 두 개만 구독한다
// CombatEvents.AttackStarted (Actor.Play_Attack 안 · Actor.cs:1957) → 예고 시작
// CombatEvents.HitboxSpawned (Actor.Shoot_Projectile 첫 줄 · :2342) → 히트 = 예고 끝
// 데미지는 공격 클립의 애니메이션 이벤트 `Projectile` 이 낸다. 그래서 **애니메이터를 늦추면 데미지도 같이 늦어진다**
// (별도 동기화 0 · 기준서 §G-2 ⓐ). 프로브가 이것을 실측으로 증명한다(정규화 시간 진행 = 속도에 정확히 반비례).
//
// ■ 한계(기준서 §G-2 ⓐ 그대로) — 속도 곡선 단독으로 0.5 s 는 안 된다
// Batty 히트 시각 0.081 s 에서 0.5 s 를 만들려면 k = 0.16 인데 이는 사실상 정지 화면이다.
// 자연스러운 하한 k = 0.35 를 지키면 예고는 0.23 s 까지만 늘어난다 → **나머지는 데칼·펄스·색·소리가 채운다.**
// 즉 「몹 동작이 0.5 s 느려진다」가 아니라 「0.5 s 동안 바닥 판이 채워지고 몹이 커지며 소리가 난다」가 이번 1차의 실체다.
//
// ■ 보스는 건드리지 않는다 — Boss_Anubis 가 이미 코드로 3 s 차징을 건다(기준서 §G-0).
//
// ■ 다른 WL 시스템과의 관계
// · 색: 813s 가 만든 MobHitFlash.SetEliteTint/ClearEliteTint 경로를 **그대로** 쓴다(렌더러 MPB 소유자는 하나여야 한다는 813s 교훈).
// 엘리트였다면 예고가 끝날 때 813s 의 상시 색을 다시 걸어 준다(MobHitFlash.cs 수정 0줄).
// · 크기: 813s EliteMarker 가 대입한 스케일을 「기준」으로 잡고 곱한 뒤 그 기준으로 되돌린다(누적 0).
// · 히트스톱: 811gh ImpactTier 가 예고 중인 몹에게는 히트스톱을 건너뛴다(기준서 §G-4 · ImpactTier.cs +7줄).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Telegraph
{
public static class Telegraph
{
enum Phase { None = 0, Charging = 1, Fading = 2 }
sealed class State
{
public Actor actor;
public Animator anim;
public Phase phase;
public float startTime, plannedSeconds, hitSeconds, origSpeed, appliedSpeed, targetSeconds;
public float fadeUntil, guardUntil;
public bool speedApplied, tinted, pulsed, flashed, wasElite, corrected;
public Vector3 baseScale;
public int decalIdx;
public int attackIndex;
public TelegraphDanger danger;
public RuntimeAnimatorController controller;
public int activeIndex;
}
static readonly Dictionary<Actor, State> s_states = new Dictionary<Actor, State>(32);
static State[] s_active = new State[16];
static int s_activeCount;
static readonly Dictionary<RuntimeAnimatorController, float[]> s_hitTimes = new Dictionary<RuntimeAnimatorController, float[]>(16);
static readonly Dictionary<AnimationClip, float> s_clipHit = new Dictionary<AnimationClip, float>(32);
static readonly List<AnimatorClipInfo> s_clipInfo = new List<AnimatorClipInfo>(4);
static readonly List<AnimationClip> s_tmpClips = new List<AnimationClip>(8);
static FieldInfo s_tableField;
static bool s_tableLooked;
static readonly float[] s_sfxTimes = new float[8];
static int s_sfxHead;
/// <summary>
/// 815b 스테이지 진행기가 스테이지별 예고 배수를 여기에 넣는다(기본 1 = 무변경).
/// 🔴 815b 가 아직 병합되지 않았으므로 지금 값은 항상 1 이다 — 리플렉션/옵션 참조를 쓰지 않는다(같은 어셈블리라 병합 뒤 한 줄로 연결된다).
/// </summary>
public static float StageScale = 1f;
// ── 진단(프로브가 읽는다)
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;
public static float LastPlannedSeconds = -1f, LastHitSeconds = -1f, LastOrigSpeed = -1f, LastAppliedSpeed = -1f, LastTargetSeconds = -1f;
public static bool LastWasRanged, LastWasClamped;
public static string LastInfo = "";
public static int ActiveCount { get { return s_activeCount; } }
public static int StateCount { get { return s_states.Count; } }
static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.AttackStarted.Add(OnAttackStarted);
CombatEvents.HitboxSpawned.Add(OnHitboxSpawned);
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
Subscribed = true;
}
/// <summary>프로브용 — 에디트 모드에서 구독을 강제한다(RuntimeInitializeOnLoadMethod 가 안 도는 환경).</summary>
public static void EnsureSubscribed()
{
if (Subscribed) return;
Register();
}
/// <summary>811gh ImpactTier 가 부른다 — 이 몹이 지금 예고(충전) 중인가.</summary>
public static bool IsTelegraphing(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging;
}
/// <summary>프로브용 — 이 몹에 적용된 애니메이터 속도(예고 중이 아니면 -1).</summary>
public static float AppliedSpeedOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.appliedSpeed : -1f;
}
/// <summary>프로브용 — 계획된 예고 초(예고 중이 아니면 -1).</summary>
public static float PlannedSecondsOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.plannedSeconds : -1f;
}
/// <summary>프로브용 — 이 몹이 쓰는 데칼 풀 인덱스(-1 = 없음).</summary>
public static int DecalIndexOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) ? s.decalIdx : -1;
}
// ───────────────────────────────────────── 이벤트
static void OnAttackStarted(in AttackStartedEvent e)
{
var st = St;
if (st == null || !WLTelegraphSettings.Enabled) { SkippedDisabled++; return; }
var actor = e.actor;
if (actor == null) return;
if (!actor.IsRole(eRole.Mob)) { SkippedNotMob++; return; }
if (st.bossUntouched && actor.IsSubRole(eSubRol.Boss)) { SkippedBoss++; return; }
var table = GetTable(actor);
if (table == null) { SkippedNoTable++; return; }
string proj = table.s_Porjectile1;
float projLifetime = table.f_ProjectileLifeTime1;
bool ranged = projLifetime >= st.rangedLifetimeThreshold
|| (!string.IsNullOrEmpty(proj) && proj.IndexOf("Range", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool elite = actor.IsSubRole(eSubRol.Elite) || table.e_MonsterType == eSubRol.Elite;
float target = elite ? st.telegraphSecondsElite : (ranged ? st.telegraphSecondsRanged : st.telegraphSecondsNormal);
target *= (StageScale > 0f ? StageScale : 1f);
if (target <= 0.01f) return;
TelegraphRunner.Ensure(); // Play 에서 틱 러너 보장(에디트 모드에서는 아무것도 만들지 않는다)
var s = GetState(actor);
EndInternal(s, false, true); // 이전 예고가 남아 있으면 먼저 정리(원복 보장)
s.anim = actor.m_animation;
s.attackIndex = e.attackIndex;
s.controller = s.anim != null ? s.anim.runtimeAnimatorController : null;
s.hitSeconds = ResolveHitSeconds(s.controller, e.attackIndex, st);
s.origSpeed = e.animSpeed > 0.0001f ? e.animSpeed : (s.anim != null && s.anim.speed > 0.0001f ? s.anim.speed : 1f);
s.corrected = false;
s.danger = elite ? st.dangerElite : (ranged ? st.dangerNormalRanged : st.dangerNormalMelee);
s.startTime = TelegraphRunner.Now;
s.targetSeconds = target;
s.phase = Phase.Charging;
ApplySpeed(s, st, target);
// ── ① 지면 데칼 ────────────────────────────────────────────────
s.decalIdx = -1;
if (st.decalEnabled)
{
var box = TelegraphShape.Get(proj);
s.decalIdx = TelegraphDecal.Acquire(actor, in box, ranged, projLifetime, s.danger);
if (s.decalIdx >= 0) DecalOn++; else DecalSkipped++;
}
// ── ⓓ 스케일 펄스(813s EliteMarker 가 대입한 값을 기준으로 잡는다) ──
if (st.pulseEnabled && st.pulseScale > 1.0001f)
{
s.baseScale = actor.transform.localScale;
s.pulsed = true;
PulseApplied++;
}
// ── ③ 색 규약(MobHitFlash MPB 경로 · 813s 와 같은 소유자) ───────
if (st.tintEnabled)
{
s.wasElite = WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor);
bool unavoid = s.danger == TelegraphDanger.Unavoidable;
s.tinted = WL.Combat.Reaction.MobHitFlash.SetEliteTint(actor,
unavoid ? st.tintAlbedoUnavoidable : st.tintAlbedoAvoidable,
unavoid ? st.tintEmissionUnavoidable : st.tintEmissionAvoidable);
if (s.tinted) TintApplied++;
}
// ── ④ 예고 SFX(동시 상한) ─────────────────────────────────────
PlaySfx(st, actor);
Activate(s);
StartCount++;
LastTargetSeconds = target; LastWasRanged = ranged;
if (Verbose)
Debug.Log("[Telegraph] 시작 " + actor.name + " 등급=" + (elite ? "엘리트" : ranged ? "잡몹(원거리)" : "잡몹")
+ " 목표 " + target.ToString("F2") + "s · 히트시각 " + s.hitSeconds.ToString("F3")
+ "s · 속도 " + s.origSpeed.ToString("F2") + "→" + s.appliedSpeed.ToString("F2")
+ " · 실제 예고 " + s.plannedSeconds.ToString("F3") + "s · 판 " + (s.decalIdx >= 0 ? "O" : "X"));
}
static void OnHitboxSpawned(in HitboxSpawnedEvent e)
{
State s;
if (e.shooter == null || !s_states.TryGetValue(e.shooter, out s) || s.phase != Phase.Charging) return;
EndInternal(s, true, false);
}
static void OnKilled(in KilledEvent e)
{
State s;
if (e.victim == null || !s_states.TryGetValue(e.victim, out s)) return;
EndInternal(s, false, true);
}
static void OnSpawned(in SpawnedEvent e)
{
// 풀 재사용 몹 — 남아 있던 예고 상태를 지운다(스케일 감시 포함 · 813s EliteMarker 의 대입과 충돌 0)
State s;
if (e.actor == null || !s_states.TryGetValue(e.actor, out s)) return;
EndInternal(s, false, true);
s.guardUntil = 0f;
}
// ───────────────────────────────────────── 틱
internal static void Tick(float now)
{
if (s_activeCount == 0) return;
var st = St;
for (int i = s_activeCount - 1; i >= 0; i--)
{
var s = s_active[i];
if (s == null) continue;
if (s.actor == null) { HardClear(s); continue; }
if (s.phase == Phase.Charging)
{
if (st == null) { EndInternal(s, false, true); continue; }
float elapsed = now - s.startTime;
// 히트가 안 온다(피격·사망·중단) → 강제 원복
if (elapsed > Mathf.Max(0.1f, st.maxTelegraphSeconds) || !s.actor.gameObject.activeInHierarchy)
{ TimeoutCount++; EndInternal(s, false, true); continue; }
if (!s.corrected) CorrectFromLiveClip(s, st);
float planned = Mathf.Max(0.0001f, s.plannedSeconds);
float t = Mathf.Clamp01(elapsed / planned);
if (s.decalIdx >= 0) { TelegraphDecal.Follow(s.decalIdx, s.actor); TelegraphDecal.SetFill(s.decalIdx, t); }
if (s.pulsed)
{
float k = t * t; // 뒤로 갈수록 빨리 커진다(선딜 느낌)
float mul = 1f + (st.pulseScale - 1f) * k;
s.actor.transform.localScale = s.baseScale * mul;
}
// 히트 직전 흰 섬광
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;
}
}
else if (s.phase == Phase.Fading)
{
if (now >= s.fadeUntil) { ClearTint(s, st); s.phase = Phase.None; }
}
// 스케일 복원 감시 — 811b WLHitFeel 스케일 펀치가 예고 중에 잡은 기준값으로 되돌려 놓는 것을 되잡는다
if (s.phase == Phase.None)
{
if (s.guardUntil > 0f && now < s.guardUntil && s.actor != null && s.baseScale.sqrMagnitude > 0.0001f)
{
var cur = s.actor.transform.localScale;
if (Mathf.Abs(cur.x - s.baseScale.x) > s.baseScale.x * 0.01f) s.actor.transform.localScale = s.baseScale;
}
else { Deactivate(s); }
}
}
}
// ───────────────────────────────────────── 내부
static void ApplySpeed(State s, WLTelegraphSettings st, float target)
{
s.appliedSpeed = s.origSpeed;
s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, s.origSpeed);
s.speedApplied = false;
LastHitSeconds = s.hitSeconds; LastOrigSpeed = s.origSpeed;
if (!st.speedCurveEnabled || s.anim == null) { LastAppliedSpeed = s.appliedSpeed; LastPlannedSeconds = s.plannedSeconds; return; }
float desired = s.hitSeconds / target; // 절대 애니메이터 속도
float floorSpeed = s.origSpeed * Mathf.Clamp(st.minSpeedMultiplier, 0.01f, 1f);
float applied = Mathf.Clamp(desired, floorSpeed, s.origSpeed); // 빨라지지는 않는다
bool clamped = desired < floorSpeed - 0.0001f;
if (clamped) SpeedClamped++;
s.anim.speed = applied;
s.appliedSpeed = applied;
s.speedApplied = true;
s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, applied);
SpeedApplied++;
LastAppliedSpeed = applied; LastPlannedSeconds = s.plannedSeconds; LastWasClamped = clamped;
}
/// <summary>첫 틱에서 실제 재생 중인 클립을 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 할당 0).</summary>
static void CorrectFromLiveClip(State s, WLTelegraphSettings st)
{
s.corrected = true;
if (!st.correctFromLiveClip || s.anim == null || s.controller == null) return;
s_clipInfo.Clear();
s.anim.GetCurrentAnimatorClipInfo(0, s_clipInfo);
AnimationClip best = null; float bestW = 0f;
for (int i = 0; i < s_clipInfo.Count; i++)
if (s_clipInfo[i].clip != null && s_clipInfo[i].weight >= bestW) { bestW = s_clipInfo[i].weight; best = s_clipInfo[i].clip; }
if (best == null) return;
float ht = ClipHitTime(best, st);
if (ht <= 0f || Mathf.Abs(ht - s.hitSeconds) <= 0.005f) return;
// 캐시(다음 공격부터는 처음부터 정확하다)
float[] arr;
if (s_hitTimes.TryGetValue(s.controller, out arr) && arr != null)
{
int idx = Mathf.Clamp(s.attackIndex, 0, arr.Length - 1);
if (arr.Length > 0) arr[idx] = ht;
}
float elapsed = TelegraphRunner.Now - s.startTime;
s.hitSeconds = ht;
float target = s.targetSeconds > 0f ? s.targetSeconds : s.plannedSeconds;
ApplySpeed(s, st, target);
s.startTime = TelegraphRunner.Now - elapsed; // 이미 흐른 만큼은 유지
HitTimeCorrected++;
}
static void EndInternal(State s, bool byHit, bool forced)
{
if (s.phase == Phase.None && !s.speedApplied && !s.tinted && !s.pulsed && s.decalIdx < 0) return;
var st = St;
float now = TelegraphRunner.Now;
if (s.speedApplied && s.anim != null)
{
// 원본 값으로 되돌린다(Play_Attack 이 정한 FinalAttackSpeed 반영값). 원본이 그 사이 speed 를 바꿨으면(피격·사망) 건드리지 않는다.
if (Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; }
}
s.speedApplied = false;
if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; }
if (s.pulsed && s.actor != null)
{
s.actor.transform.localScale = s.baseScale;
s.guardUntil = now + (st != null ? Mathf.Max(0f, st.pulseRestoreGuardSeconds) : 0f);
PulseRestored++;
}
s.pulsed = false;
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++; }
s.flashed = true;
s.phase = Phase.Fading;
s.fadeUntil = now + st.flashSeconds;
}
else
{
ClearTint(s, st);
s.phase = Phase.None;
}
s.flashed = false;
EndCount++;
if (s.guardUntil <= now && s.phase == Phase.None) Deactivate(s);
}
static void ClearTint(State s, WLTelegraphSettings st)
{
if (!s.tinted || s.actor == null) { s.tinted = false; return; }
s.tinted = false;
// 813s 엘리트 상시 색이 원래 걸려 있었다면 그 색을 되돌려 준다(MobHitFlash.cs 수정 0줄)
var es = WL.Combat.Reaction.WLEliteSettings.Instance;
if (s.wasElite && es != null && WL.Combat.Reaction.WLEliteSettings.Enabled && es.tintEnabled
&& WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, es.tintAlbedo, es.tintEmission))
{ TintRestoredElite++; return; }
WL.Combat.Reaction.MobHitFlash.ClearEliteTint(s.actor);
}
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;
Deactivate(s);
}
static void PlaySfx(WLTelegraphSettings st, Actor actor)
{
if (!st.sfxEnabled) return;
if (st.sfxIndex < 0 || st.sfxIndex >= (int)eSound.Max) return;
if (!TakeSfxToken(st)) return; // 기준서 §G-4 동시 상한
if (!SoundInfo.isIns || SoundInfo.Ins == null) { SfxNoManager++; return; }
SoundInfo.Ins.Play_OneShot_byDistance((eSound)st.sfxIndex, actor.transform.position, st.sfxVolume);
SfxPlayed++;
}
/// <summary>예고 SFX 동시 상한 토큰(창 안에서 sfxMaxConcurrent 개까지). 프로브가 사운드 매니저 없이 상한만 검증할 때도 부른다.</summary>
public static bool TakeSfxToken(WLTelegraphSettings st)
{
if (st == null) return false;
float now = TelegraphRunner.Now;
float window = Mathf.Max(0.01f, st.sfxWindowSeconds);
int recent = 0;
for (int i = 0; i < s_sfxTimes.Length; i++) if (now - s_sfxTimes[i] < window) recent++;
if (recent >= Mathf.Max(1, st.sfxMaxConcurrent)) { SfxThrottled++; return false; }
s_sfxTimes[s_sfxHead] = now;
s_sfxHead = (s_sfxHead + 1) % s_sfxTimes.Length;
return true;
}
static State GetState(Actor actor)
{
State s;
if (s_states.TryGetValue(actor, out s)) return s;
s = new State { actor = actor, decalIdx = -1, activeIndex = -1 };
s_states.Add(actor, s);
return s;
}
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)
{
if (s == null) return;
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;
}
// ── 몹 테이블(원본 protected 필드) — 리플렉션 1회 · 클래스 참조라 호출당 할당 0
static MonsterTableData GetTable(Actor actor)
{
var mob = actor as MobActor;
if (mob == null) return null;
if (!s_tableLooked)
{
s_tableField = typeof(MobActor).GetField("m_MonsterTableData", BindingFlags.Instance | BindingFlags.NonPublic);
s_tableLooked = true;
}
if (s_tableField == null) return null;
return s_tableField.GetValue(mob) as MonsterTableData;
}
// ── 공격 클립의 히트 이벤트 시각(초) — 컨트롤러당 1회 스캔 후 캐시
static float ResolveHitSeconds(RuntimeAnimatorController rac, int attackIndex, WLTelegraphSettings st)
{
if (rac == null) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); }
float[] arr;
if (!s_hitTimes.TryGetValue(rac, out arr))
{
arr = BuildHitTimes(rac, st);
s_hitTimes[rac] = arr;
}
if (arr == null || arr.Length == 0) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); }
int i = Mathf.Clamp(attackIndex, 0, arr.Length - 1);
float t = arr[i];
if (t <= 0f) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); }
return t;
}
static float[] BuildHitTimes(RuntimeAnimatorController rac, WLTelegraphSettings st)
{
s_tmpClips.Clear();
var clips = rac.animationClips; // 컨트롤러당 1회
if (clips != null)
for (int i = 0; i < clips.Length; i++)
{
var c = clips[i];
if (c == null) continue;
string n = c.name;
if (n.IndexOf("attack", System.StringComparison.OrdinalIgnoreCase) < 0) continue;
if (n.IndexOf("skill", System.StringComparison.OrdinalIgnoreCase) >= 0) continue; // 스킬은 Play_Skill 경로
if (ClipHitTime(c, st) <= 0f) continue;
if (!s_tmpClips.Contains(c)) s_tmpClips.Add(c);
}
if (s_tmpClips.Count == 0) return null;
s_tmpClips.Sort(CompareClipName);
var res = new float[s_tmpClips.Count];
for (int i = 0; i < s_tmpClips.Count; i++) res[i] = ClipHitTime(s_tmpClips[i], st);
return res;
}
static int CompareClipName(AnimationClip a, AnimationClip b)
{
if (a == null) return b == null ? 0 : 1;
if (b == null) return -1;
return string.CompareOrdinal(a.name, b.name);
}
static float ClipHitTime(AnimationClip clip, WLTelegraphSettings st)
{
float t;
if (s_clipHit.TryGetValue(clip, out t)) return t;
t = -1f;
var evs = clip.events; // 클립당 1회
var names = st.hitEventNames;
if (evs != null && names != null)
for (int i = 0; i < evs.Length; i++)
for (int k = 0; k < names.Length; k++)
if (!string.IsNullOrEmpty(names[k]) && evs[i].functionName == names[k])
{ if (t < 0f || evs[i].time < t) t = evs[i].time; }
s_clipHit[clip] = t;
return t;
}
// ───────────────────────────────────────── 정리 · 프로브
/// <summary>전부 원복한다(씬 전환 · C8 · 러너 파괴 · 프로브 정리).</summary>
public static void RestoreAll()
{
var st = St;
foreach (var kv in s_states)
{
var s = kv.Value;
if (s.speedApplied && s.anim != null && Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; }
s.speedApplied = false;
if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; }
if (s.pulsed && s.actor != null) { s.actor.transform.localScale = s.baseScale; PulseRestored++; }
s.pulsed = false;
ClearTint(s, st);
s.phase = Phase.None; s.guardUntil = 0f; s.flashed = false;
}
for (int i = 0; i < s_activeCount; i++) if (s_active[i] != null) s_active[i].activeIndex = -1;
System.Array.Clear(s_active, 0, s_active.Length);
s_activeCount = 0;
TelegraphDecal.ReleaseAll();
}
/// <summary>상태를 통째로 버린다(파괴된 Actor 참조 방지 · 씬 전환).</summary>
public static void ClearAll()
{
RestoreAll();
s_states.Clear();
}
public static void ResetDiagnostics()
{
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;
LastPlannedSeconds = LastHitSeconds = LastOrigSpeed = LastAppliedSpeed = LastTargetSeconds = -1f;
LastWasRanged = LastWasClamped = false;
LastInfo = "";
for (int i = 0; i < s_sfxTimes.Length; i++) s_sfxTimes[i] = -999f;
}
/// <summary>프로브용 — 클립 히트 시각 캐시를 비운다(에셋을 갈아 끼운 뒤 재측정).</summary>
public static void ClearClipCache() { s_hitTimes.Clear(); s_clipHit.Clear(); }
/// <summary>프로브용 — 컨트롤러의 공격 클립 히트 시각 표(초). null = 못 찾음.</summary>
public static float[] HitSecondsTable(RuntimeAnimatorController rac)
{
var st = St;
if (rac == null || st == null) return null;
float[] arr;
if (!s_hitTimes.TryGetValue(rac, out arr)) { arr = BuildHitTimes(rac, st); s_hitTimes[rac] = arr; }
return arr;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2aae7ce96988104094a9e98e882a66b

View File

@ -0,0 +1,387 @@
// ─────────────────────────────────────────────────────────────────────────────
// TelegraphDecal.cs — 지면 예고 판(① 기준서 §G-1) · 판정 범위와 100 % 같은 크기 · 풀링 · GC 0
//
// PD 지시 #815-5 · 발주서 WL-815f §1-2
//
// ■ 왜 DecalProjector 가 아닌가 (기준서 §G-1 실측)
// URP-Balanced-Renderer 의 RendererFeatures 는 **SSAO 1개뿐**이라 Decal Renderer Feature 가 없다 →
// DecalProjector 는 화면에 아예 안 나온다. 보유 인디케이터 20종이 나오는 이유는 그것들이 **파티클/쿼드**이기 때문이다.
// → 여기서도 같은 방식(런타임 쿼드 2장 · 새 셰이더/머티리얼/메시 에셋 0)을 쓴다.
//
// ■ 판 구성 (드로우 +2 / 판 · 동시 상한으로 묶는다)
// 바깥 판(범위) = 콜라이더 크기 그대로 · 낮은 알파 → 「여기 맞는다」
// 안쪽 판(진행) = 같은 자리에서 0 → 100 % 로 **채워진다** · 높은 알파 → 「언제 맞는다」
// 두 판 모두 몹의 회전을 따라간다(원본 Play_Attack 이 transform.LookAt 으로 계속 돌린다).
//
// ■ 크기의 출처 = TelegraphShape (투사체 프리팹 BoxCollider) — 코드 상수 0.
// 근접: size.x × size.z 사각형, 중심 = 발사 위치 + 회전된 collider.center 의 XZ.
// 원거리: 폭 = size.x, 길이 = 투사체 속도 × 수명(기준서 §G-6 「직선 데칼」 · rangedMaxLength 로 자른다).
//
// ■ 색 = 위험 색 규약(기준서 §G-3): 노랑 = 피할 수 있다 · 빨강 = 못 피한다.
// ■ IndicatorInfo.Show_Indicator 재사용 = SO 의 indicatorPrefabName 이 비어 있지 않을 때 덧칠로 켠다(§G-1 「원본 공개 API」).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Telegraph
{
public static class TelegraphDecal
{
sealed class Plate
{
public GameObject root;
public Transform tf, baseTf, fillTf;
public MeshRenderer baseMr, fillMr;
public bool inUse;
public bool ranged;
public Vector3 localSize; // x = 폭 · z = 길이(실측 콜라이더에서 온다)
public Vector3 localOffset; // 몹 로컬 기준 판 중심(콜라이더 center + 전방 오프셋)
public GameObject indicator; // IndicatorInfo 풀에서 빌린 덧칠(없으면 null)
}
static Plate[] s_pool;
static int s_inUse;
static Mesh s_quad;
static Material[] s_matBase, s_matFill; // [0] = Avoidable · [1] = Unavoidable
static Shader s_shader;
static int s_colorPropId;
static bool s_hasColorProp, s_shaderTried;
static Transform s_root;
// ── 진단(프로브가 읽는다)
public static int AcquireCount, ReleaseCount, BudgetSkipCount, PoolGrowCount, IndicatorBorrowCount, IndicatorMissCount;
public static string LastInfo = "";
public static int InUse { get { return s_inUse; } }
public static int PoolSize { get { return s_pool == null ? 0 : s_pool.Length; } }
public static bool ShaderReady { get { return s_shader != null; } }
public static string ShaderName { get { return s_shader == null ? "(없음)" : s_shader.name; } }
static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } }
// ───────────────────────────────────────── 공개 API
/// <summary>판을 하나 켠다. 반환 = 풀 인덱스(-1 = 예산 초과 또는 비활성).</summary>
public static int Acquire(Actor actor, in TelegraphBox box, bool ranged, float projectileLifetime, TelegraphDanger danger)
{
var st = St;
if (st == null || !st.decalEnabled || actor == null) return -1;
if (!EnsureShader(st)) return -1;
if (s_inUse >= Mathf.Max(1, st.maxConcurrent)) { BudgetSkipCount++; return -1; }
int idx = Take(st);
if (idx < 0) return -1;
var p = s_pool[idx];
// ── 크기(판정과 같은 값) ────────────────────────────────────────
float scale = st.sizeScale <= 0f ? 1f : st.sizeScale;
float width = Mathf.Max(0.05f, box.size.x * scale);
float length;
if (ranged)
{
float travel = box.speed > 0f ? box.speed * Mathf.Max(0f, projectileLifetime) : box.size.z;
length = Mathf.Clamp(travel * scale, box.size.z * scale, Mathf.Max(1f, st.rangedMaxLength));
}
else length = Mathf.Max(0.05f, box.size.z * scale);
p.ranged = ranged;
p.localSize = new Vector3(width, 1f, length);
// 판 중심 = 발사 위치(전방 forwardOffset) + 콜라이더 center 의 XZ.
// 원거리는 투사체가 앞으로 나아가므로 진행 구간의 한가운데로 민다.
float centerZ = st.forwardOffset + box.center.z * scale + (ranged ? length * 0.5f : 0f);
p.localOffset = new Vector3(box.center.x * scale, 0f, centerZ);
int di = danger == TelegraphDanger.Unavoidable ? 1 : 0;
p.baseMr.sharedMaterial = s_matBase[di];
p.fillMr.sharedMaterial = s_matFill[di];
p.baseTf.localScale = new Vector3(width, 1f, length);
p.root.SetActive(true);
Follow(idx, actor);
SetFill(idx, 0f);
// ── IndicatorInfo.Show_Indicator 재사용(덧칠 · 원본 공개 API · 로드/풀링은 원본이 한다)
BorrowIndicator(st, p, Mathf.Max(width, length));
AcquireCount++;
return idx;
}
/// <summary>몹의 현재 위치·회전을 따라간다(회전 추종).</summary>
public static void Follow(int idx, Actor actor)
{
if (idx < 0 || s_pool == null || idx >= s_pool.Length) return;
var p = s_pool[idx];
if (!p.inUse || actor == null) return;
var st = St;
if (st == null) return;
var atf = actor.transform;
Quaternion rot = st.followRotation ? Quaternion.Euler(0f, atf.eulerAngles.y, 0f) : p.tf.rotation;
Vector3 pos = atf.position + rot * p.localOffset;
pos.y = atf.position.y + st.groundYOffset;
p.tf.SetPositionAndRotation(pos, rot);
if (p.indicator != null) p.indicator.transform.SetPositionAndRotation(pos, rot);
}
/// <summary>진행도 0~1 을 판에 반영한다(채워지는 연출).</summary>
public static void SetFill(int idx, float t01)
{
if (idx < 0 || s_pool == null || idx >= s_pool.Length) return;
var p = s_pool[idx];
if (!p.inUse) return;
var st = St;
if (st == null) return;
float t = Mathf.Clamp01(t01);
float w = p.localSize.x, l = p.localSize.z;
if (st.fillMode == TelegraphFill.CenterExpand)
{
p.fillTf.localScale = new Vector3(w * t, 1f, l * t);
p.fillTf.localPosition = Vector3.zero;
}
else // ForwardSweep — 몹 쪽 가장자리에서 앞으로 채워진다
{
p.fillTf.localScale = new Vector3(w, 1f, Mathf.Max(0.0001f, l * t));
p.fillTf.localPosition = new Vector3(0f, 0.01f, -l * 0.5f + l * t * 0.5f);
}
}
/// <summary>판을 끈다(풀 반납).</summary>
public static void Release(int idx)
{
if (idx < 0 || s_pool == null || idx >= s_pool.Length) return;
var p = s_pool[idx];
if (!p.inUse) return;
p.inUse = false;
s_inUse--;
if (p.root != null) p.root.SetActive(false);
if (p.indicator != null) { p.indicator.SetActive(false); p.indicator = null; }
ReleaseCount++;
}
/// <summary>전부 끈다(씬 전환 · C8 · 프로브 정리).</summary>
public static void ReleaseAll()
{
if (s_pool == null) return;
for (int i = 0; i < s_pool.Length; i++) Release(i);
s_inUse = 0;
}
/// <summary>풀 오브젝트까지 파기한다(프로브 종료 · 에디트 모드 잔재 0).</summary>
public static void DestroyAll()
{
ReleaseAll();
if (s_pool != null)
for (int i = 0; i < s_pool.Length; i++)
if (s_pool[i] != null && s_pool[i].root != null) DestroyObj(s_pool[i].root);
s_pool = null;
if (s_root != null) { DestroyObj(s_root.gameObject); s_root = null; }
if (s_matBase != null) for (int i = 0; i < s_matBase.Length; i++) if (s_matBase[i] != null) DestroyObj(s_matBase[i]);
if (s_matFill != null) for (int i = 0; i < s_matFill.Length; i++) if (s_matFill[i] != null) DestroyObj(s_matFill[i]);
if (s_quad != null) DestroyObj(s_quad);
s_matBase = s_matFill = null; s_quad = null; s_shader = null; s_shaderTried = false;
}
public static void ResetDiagnostics()
{
AcquireCount = ReleaseCount = BudgetSkipCount = PoolGrowCount = IndicatorBorrowCount = IndicatorMissCount = 0;
LastInfo = "";
}
/// <summary>프로브용 — 판의 월드 크기(x = 폭 · z = 길이)와 중심을 돌려준다(콜라이더 대조용).</summary>
public static bool TryGetPlate(int idx, out Vector3 size, out Vector3 center, out float yaw)
{
size = Vector3.zero; center = Vector3.zero; yaw = 0f;
if (idx < 0 || s_pool == null || idx >= s_pool.Length) return false;
var p = s_pool[idx];
if (!p.inUse) return false;
size = new Vector3(p.baseTf.lossyScale.x, 0f, p.baseTf.lossyScale.z);
center = p.tf.position;
yaw = p.tf.eulerAngles.y;
return true;
}
// ───────────────────────────────────────── 내부
static int Take(WLTelegraphSettings st)
{
EnsurePool(st);
for (int i = 0; i < s_pool.Length; i++)
if (!s_pool[i].inUse && s_pool[i].root != null) { s_pool[i].inUse = true; s_inUse++; return i; }
return -1;
}
static void EnsurePool(WLTelegraphSettings st)
{
int want = Mathf.Max(1, Mathf.Max(st.maxConcurrent, st.poolWarmCount));
if (s_pool != null && s_pool.Length >= want) return;
if (s_root == null)
{
var rootGo = new GameObject("__WLTelegraphDecals");
rootGo.hideFlags = HideFlags.DontSave;
s_root = rootGo.transform;
if (Application.isPlaying) Object.DontDestroyOnLoad(rootGo);
}
int from = s_pool == null ? 0 : s_pool.Length;
var next = new Plate[want];
if (s_pool != null) System.Array.Copy(s_pool, next, s_pool.Length);
for (int i = from; i < want; i++) next[i] = MakePlate(i);
s_pool = next;
PoolGrowCount++;
}
static Plate MakePlate(int i)
{
var p = new Plate();
p.root = new GameObject("WLTelegraphPlate_" + i);
p.root.hideFlags = HideFlags.DontSave;
p.root.transform.SetParent(s_root, false);
p.tf = p.root.transform;
p.baseTf = MakeQuad("Range", p.tf, out p.baseMr);
p.fillTf = MakeQuad("Fill", p.tf, out p.fillMr);
p.fillTf.localPosition = new Vector3(0f, 0.01f, 0f);
p.root.SetActive(false);
return p;
}
static Transform MakeQuad(string name, Transform parent, out MeshRenderer mr)
{
var go = new GameObject(name);
go.hideFlags = HideFlags.DontSave;
go.transform.SetParent(parent, false);
var mf = go.AddComponent<MeshFilter>();
mf.sharedMesh = EnsureQuad();
mr = go.AddComponent<MeshRenderer>();
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
return go.transform;
}
/// <summary>XZ 평면 1×1 쿼드(위를 본다). 내장 Quad.fbx 의존 0 · 메시 에셋 0.</summary>
static Mesh EnsureQuad()
{
if (s_quad != null) return s_quad;
var m = new Mesh();
m.name = "__WLTelegraphQuad";
m.hideFlags = HideFlags.DontSave;
m.vertices = new[]
{
new Vector3(-0.5f, 0f, -0.5f), new Vector3(-0.5f, 0f, 0.5f),
new Vector3( 0.5f, 0f, 0.5f), new Vector3( 0.5f, 0f, -0.5f),
};
m.uv = new[] { new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(1f, 0f) };
m.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up };
m.triangles = new[] { 0, 1, 2, 0, 2, 3 };
m.RecalculateBounds();
s_quad = m;
return m;
}
static bool EnsureShader(WLTelegraphSettings st)
{
if (s_shader != null && s_matBase != null) return true;
if (s_shaderTried && s_shader == null) return false;
s_shaderTried = true;
var names = st.decalShaderNames;
if (names != null)
for (int i = 0; i < names.Length && s_shader == null; i++)
if (!string.IsNullOrEmpty(names[i])) s_shader = Shader.Find(names[i]);
if (s_shader == null) { LastInfo = "셰이더 후보를 못 찾음 — 데칼 비활성"; return false; }
s_hasColorProp = false;
var props = st.decalColorProperties;
if (props != null)
for (int i = 0; i < props.Length; i++)
if (!string.IsNullOrEmpty(props[i]) && s_shader.FindPropertyIndex(props[i]) >= 0)
{ s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; }
s_matBase = new Material[2];
s_matFill = new Material[2];
for (int d = 0; d < 2; d++)
{
Color c = d == 1 ? st.colorUnavoidable : st.colorAvoidable;
s_matBase[d] = MakeMat(st, "__WLTelegraphBase" + d, c, st.plateAlpha);
s_matFill[d] = MakeMat(st, "__WLTelegraphFill" + d, c, st.fillAlpha);
}
LastInfo = "셰이더 " + s_shader.name + " · 색 프로퍼티 " + (s_hasColorProp ? "O" : "X");
return true;
}
static Material MakeMat(WLTelegraphSettings st, string name, Color c, float alpha)
{
var m = new Material(s_shader);
m.name = name;
m.hideFlags = HideFlags.DontSave;
// URP Unlit 반투명 세팅(런타임) — 렌더링 배관이라 SO 튜닝 값이 아니다(811gh DashAfterImage 와 같은 방식).
if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f);
if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f);
if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f);
if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f);
if (m.HasProperty("_Cull")) m.SetFloat("_Cull", 0f);
m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
m.DisableKeyword("_ALPHATEST_ON");
m.SetOverrideTag("RenderType", "Transparent");
m.renderQueue = st.decalRenderQueue > 0 ? st.decalRenderQueue : 3050;
var col = new Color(c.r, c.g, c.b, Mathf.Clamp01(alpha));
if (s_hasColorProp) m.SetColor(s_colorPropId, col);
m.color = col;
return m;
}
static void BorrowIndicator(WLTelegraphSettings st, Plate p, float footprint)
{
if (string.IsNullOrEmpty(st.indicatorPrefabName)) return;
if (!IndicatorInfo.isIns || IndicatorInfo.Ins == null) { IndicatorMissCount++; return; }
var plate = p;
float want = footprint;
IndicatorInfo.Ins.Make_Indicator(st.indicatorPrefabName, go =>
{
if (go == null || !plate.inUse) return;
go.transform.SetPositionAndRotation(plate.tf.position, plate.tf.rotation);
float s = st.indicatorAutoScale ? IndicatorScale(go, want) : 1f;
go.transform.localScale = new Vector3(s, s, s);
go.SetActive(true);
plate.indicator = go;
IndicatorBorrowCount++;
});
}
/// <summary>인디케이터의 파티클 시작 크기를 「고유 지름」으로 보고, 원하는 발자국 크기에 맞는 배수를 낸다(실측 · 811s 방식의 축약).</summary>
static float IndicatorScale(GameObject go, float wantDiameter)
{
float native = 0f;
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
for (int i = 0; i < systems.Length; i++)
{
var main = systems[i].main;
float sz = main.startSizeMultiplier * Mathf.Max(0.0001f, systems[i].transform.lossyScale.x);
if (sz > native) native = sz;
}
if (native <= 0.0001f) return 1f;
return Mathf.Clamp(wantDiameter / native, 0.05f, 20f);
}
static void DestroyObj(Object o)
{
if (o == null) return;
if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4365a2a5e7f90a945849c31ab081f81e

View File

@ -0,0 +1,81 @@
// ─────────────────────────────────────────────────────────────────────────────
// TelegraphRunner.cs — 815f 전용 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음 · GC 0)
//
// PD 지시 #815-5 · 발주서 WL-815f
//
// ■ 왜 ImpactRunner(811gh) · WLReactionRunner(811def) 에 얹지 않았나
// 같은 파일에 틱을 붙이면 **병합 충돌**이 난다(811gh 가 811i 때문에 파일을 나눈 것과 같은 이유).
// 새 Manager/Singleton 패러다임이 아니라 기존 러너와 **같은 패턴**을 하나 더 두는 것이다.
//
// ■ 무엇을 하나
// Telegraph.Tick — 데칼 위치·회전 추종 · 채워지는 진행 · 스케일 펄스 · 히트 직전 흰 섬광 · 타임아웃 원복.
// 파괴·비활성·앱 종료·씬 전환 시 애니메이터 속도 / 스케일 / MPB 색 / 데칼을 전부 원복한다.
//
// ■ 에디트 모드
// 프로브가 Play 없이 검증할 수 있게 ForceTick(delta) 를 둔다(러너 오브젝트 없이 같은 틱 1회 + 시뮬레이션 시계).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.SceneManagement;
namespace WL.Combat.Telegraph
{
public sealed class TelegraphRunner : MonoBehaviour
{
static TelegraphRunner s_runner;
static bool s_sceneHooked;
public static bool Exists { get { return s_runner != null; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot() { if (WLTelegraphSettings.Enabled) Ensure(); }
public static void Ensure()
{
if (s_runner != null) return;
if (!Application.isPlaying) return; // 에디트 모드에서는 오브젝트를 만들지 않는다(프로브는 ForceTick 을 쓴다)
var go = new GameObject("__WLTelegraph");
go.hideFlags = HideFlags.DontSave;
DontDestroyOnLoad(go);
s_runner = go.AddComponent<TelegraphRunner>();
if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; }
}
static void OnSceneChanged(Scene from, Scene to) { Telegraph.ClearAll(); }
// ── 에디트 모드 시뮬레이션 시계(811gh ImpactRunner 와 같은 방식)
static float s_simNow;
static bool s_simActive;
/// <summary>연출 타이밍의 기준 시각. Play = Time.unscaledTime · 프로브(ForceTick) = 시뮬레이션 시계.</summary>
public static float Now { get { return s_simActive ? s_simNow : Time.unscaledTime; } }
void Update()
{
s_simActive = false;
Telegraph.Tick(Time.unscaledTime);
}
/// <summary>프로브(에디트 모드)용 — 러너 없이 같은 틱을 1회 돌리고 시뮬레이션 시계를 delta 만큼 민다.</summary>
public static void ForceTick(float unscaledDelta)
{
if (!s_simActive) { s_simActive = true; s_simNow = Time.unscaledTime; }
s_simNow += unscaledDelta;
Telegraph.Tick(s_simNow);
}
/// <summary>프로브용 — 시뮬레이션 시계를 켜기만 한다(이벤트를 올리기 전에 기준점을 고정).</summary>
public static void BeginForceClock()
{
if (!s_simActive) { s_simActive = true; s_simNow = Time.unscaledTime; }
}
/// <summary>프로브용 — 시뮬레이션 시계를 끄고 실제 시간으로 되돌린다.</summary>
public static void ResetForceClock() { s_simActive = false; s_simNow = 0f; }
void OnDisable() { Telegraph.RestoreAll(); }
void OnApplicationQuit() { Telegraph.RestoreAll(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 228de922520081e44a9d68985af78bea

View File

@ -0,0 +1,173 @@
// ─────────────────────────────────────────────────────────────────────────────
// TelegraphShape.cs — 예고 판의 크기를 「투사체 프리팹의 BoxCollider」에서 읽어 온다(신규 데이터 0)
//
// PD 지시 #815-5 · 발주서 WL-815f §1-2 · 기준서 v1 §G-0 「공격 판정의 출처」
//
// ■ 왜 콜라이더를 읽나
// 원본은 근접도 투사체를 쏜다(MobActor.Projectile → Shoot_Projectile(s_Porjectile1, …)).
// 따라서 **맞는 범위 = 그 투사체 프리팹의 BoxCollider** 다. 데칼 크기를 코드 상수로 넣으면 판정과 어긋난다.
// 실측(기준서 §G-0): Mob/P_*_Meele 3×3×3 center 0 · Elite/P_Golem_A 3×3×3 center z 1.5 · FieldBoss/Anubis_Attack 2×2×4 center z 2.
//
// ■ 어떻게 읽나 (이름 1개당 1회 · 결과는 캐시 · 정상 흐름 GC 0)
// ① 에디터(에디트 모드·플레이 모드 공통) = AssetDatabase 로 **동기** 로드 → 프로브가 Play 없이 실측할 수 있다.
// ② 빌드/플레이 = ProjectileInfo 풀(원본이 미리 로드해 둔 인스턴스)을 이름으로 훑어 콜라이더를 읽는다.
// ③ 그래도 없으면 Addressables 비동기 로드를 1회 걸고(중복 방지), 그 사이에는 SO 기본값을 쓴다.
//
// ■ 원거리 판(기준서 §G-6 「직선 데칼」)
// 길이 = 투사체 속도 × 수명. 속도는 프리팹의 ProjectileBase.Speed(= 기본 5) 이고
// Projectile_Strait.m_Speed 가 0 보다 크면 그 값이 이긴다(원본 Projectile_Strait.Set 실측).
// 수명은 몹 테이블의 f_ProjectileLifeTime1(근접 0.1 · 원거리 1.5 실측).
//
// 🔴 원본 무수정 — 읽기만 한다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Combat.Telegraph
{
/// <summary>투사체 1종의 판정 상자(로컬 기준) + 이동 정보.</summary>
public struct TelegraphBox
{
public Vector3 size; // BoxCollider.size × 프리팹 lossyScale
public Vector3 center; // BoxCollider.center × 프리팹 lossyScale
public float speed; // 실효 이동 속도(m/s · 0 이면 제자리)
public bool resolved; // 진짜 프리팹에서 읽었는가(false = SO 기본값)
public string source; // 진단 문자열(어디서 읽었나)
}
public static class TelegraphShape
{
const string kPrefix = "Assets/Res_Addr/Projectile/";
const string kSuffix = ".prefab";
static readonly Dictionary<string, TelegraphBox> s_cache = new Dictionary<string, TelegraphBox>(32);
static readonly HashSet<string> s_inFlight = new HashSet<string>();
// ── 진단(프로브가 읽는다)
public static int ResolvedCount, FallbackCount, PoolHitCount, AssetDbHitCount, AddressablesRequestCount;
public static string LastSource = "";
public static int CacheCount { get { return s_cache.Count; } }
static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } }
/// <summary>런타임 ProjectileData.Speed 의 기본값(원본 ProjectileBase.cs:19 실측 = 5). SO 로 바꿀 수 있다.</summary>
static float DefaultSpeed { get { var st = St; return st != null && st.defaultProjectileSpeed > 0f ? st.defaultProjectileSpeed : 5f; } }
/// <summary>이 투사체 이름의 판정 상자. 아직 못 읽었으면 SO 기본값(resolved = false)을 돌려주고 비동기 로드를 1회 건다.</summary>
public static TelegraphBox Get(string projectileName)
{
if (string.IsNullOrEmpty(projectileName) || projectileName == "None") return Fallback("(투사체 없음)");
TelegraphBox box;
if (s_cache.TryGetValue(projectileName, out box)) return box;
// ① 에디터 — 동기 로드(프로브가 Play 없이 실측한다)
#if UNITY_EDITOR
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(kPrefix + projectileName + kSuffix);
if (asset != null && ReadFrom(asset.transform, "AssetDatabase", out box))
{
s_cache[projectileName] = box; ResolvedCount++; AssetDbHitCount++; LastSource = box.source; return box;
}
#endif
// ② 원본 풀(ProjectileInfo 가 table_effectlist 로 미리 로드해 둔 인스턴스)
if (ProjectileInfo.isIns && ProjectileInfo.Ins != null)
{
var root = ProjectileInfo.Ins.transform;
string baseName = BaseName(projectileName);
for (int i = 0; i < root.childCount; i++)
{
var c = root.GetChild(i);
if (!NameMatches(c.name, baseName)) continue;
if (ReadFrom(c, "ProjectileInfo 풀", out box))
{
s_cache[projectileName] = box; ResolvedCount++; PoolHitCount++; LastSource = box.source; return box;
}
}
}
// ③ Addressables 비동기 로드 1회 — 이번 예고는 기본값으로 그리고 다음 예고부터 정확해진다
if (!s_inFlight.Contains(projectileName) && AddrResourceMgr.isIns && AddrResourceMgr.Ins != null)
{
s_inFlight.Add(projectileName);
AddressablesRequestCount++;
string key = projectileName;
AddrResourceMgr.Ins.LoadObject<GameObject>(kPrefix + key + kSuffix, handle =>
{
s_inFlight.Remove(key);
var go = handle.Result;
TelegraphBox b;
if (go != null && ReadFrom(go.transform, "Addressables", out b)) { s_cache[key] = b; ResolvedCount++; }
});
}
return Fallback(projectileName);
}
/// <summary>프로브/테스트용 — 실측값을 직접 주입한다(로드 경로 없이 크기 일치를 검증할 때).</summary>
public static void Prime(string projectileName, Vector3 size, Vector3 center, float speed, string source)
{
if (string.IsNullOrEmpty(projectileName)) return;
s_cache[projectileName] = new TelegraphBox { size = size, center = center, speed = speed, resolved = true, source = source };
}
public static void ClearCache() { s_cache.Clear(); s_inFlight.Clear(); }
public static void ResetDiagnostics()
{
ResolvedCount = FallbackCount = PoolHitCount = AssetDbHitCount = AddressablesRequestCount = 0;
LastSource = "";
}
// ───────────────────────────────────────── 내부
static TelegraphBox Fallback(string why)
{
FallbackCount++;
var st = St;
var size = st != null ? st.defaultBoxSize : new Vector3(3f, 3f, 3f);
return new TelegraphBox { size = size, center = Vector3.zero, speed = 0f, resolved = false, source = "기본값 " + why };
}
static string BaseName(string projectileName)
{
int slash = projectileName.LastIndexOf('/');
return slash >= 0 ? projectileName.Substring(slash + 1) : projectileName;
}
/// <summary>풀 인스턴스 이름은 "P_Batty_Meele" 또는 "P_Batty_Meele(Clone)" 이다. 접두 비교(부분 문자열 생성 0).</summary>
static bool NameMatches(string instanceName, string baseName)
{
if (instanceName == null || instanceName.Length < baseName.Length) return false;
return string.CompareOrdinal(instanceName, 0, baseName, 0, baseName.Length) == 0;
}
static bool ReadFrom(Transform root, string source, out TelegraphBox box)
{
box = default(TelegraphBox);
if (root == null) return false;
var bc = root.GetComponent<BoxCollider>();
if (bc == null) bc = root.GetComponentInChildren<BoxCollider>(true);
if (bc == null) return false;
// 프리팹 자체 스케일까지 반영한다(실측 = 전부 1 이지만 값이 바뀌어도 따라간다)
var ls = bc.transform.lossyScale;
var size = new Vector3(Mathf.Abs(bc.size.x * ls.x), Mathf.Abs(bc.size.y * ls.y), Mathf.Abs(bc.size.z * ls.z));
var center = new Vector3(bc.center.x * ls.x, bc.center.y * ls.y, bc.center.z * ls.z);
// 🔴 속도 실측 — 프리팹에는 Speed 필드가 없다(ProjectileBase 는 ColliderLayer/m_Pierce/str_HitEffect 만 직렬화).
// 실제 속도는 런타임 ProjectileData.Speed(기본 5)이고, Projectile_Strait.m_Speed 가 0 보다 크면 그 값이 이긴다
// (Projectile_Strait.Set: `if (m_Speed > 0f) m_ProjecTileData.Speed = m_Speed;`).
// ProjectileInfo.Shoot_Projectile 은 Speed 를 건드리지 않으므로 m_Speed = 0 인 프리팹은 기본값 5 로 난다.
float speed = 0f;
var strait = root.GetComponent<Projectile_Strait>();
if (strait == null) strait = root.GetComponentInChildren<Projectile_Strait>(true);
if (strait != null) speed = strait.m_Speed > 0f ? strait.m_Speed : DefaultSpeed;
box = new TelegraphBox { size = size, center = center, speed = speed, resolved = true, source = source };
return true;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0da072705e595446984592bdb331e1e

View File

@ -0,0 +1,253 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLTelegraphSettings.cs — 적 공격 예고(텔레그래프) 값의 단일 출처(SOT · C45)
//
// PD 지시 #815-5 「적의 공격을 확실히 유저가 인지하고 피할 수 있는 매커니즘」
// 발주서 WL-815f · 기준서 v1 §G-0~§G-4 · §G-7 1차(= ① 데칼 + ⓐ 속도 곡선 + ⓓ 펄스 + ③ 색 규약 + ④ SFX)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset
// → Resources.Load<WLTelegraphSettings>("WL/WLTelegraphSettings") 로 1회 로드 후 캐시.
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 Telegraph 가 아무 것도 하지 않는다
// (C8 · 애니메이터 속도 원본 · 데칼 0 · MPB 0 · 스케일 원본 = 원본 동작 100 %).
//
// 【기준서 §G-0 실측 요약 — 이 값들의 근거】
// · 원본에는 선딜 개념이 0 이다. 데미지는 공격 클립 안의 애니메이션 이벤트 `Projectile` 이 낸다.
// 즉 「예고 시간 = 클립 시작 ~ 이벤트 시각」이고 실측 0.081~0.228 s(잡몹·엘리트 전원).
// · 그래서 ⓐ 속도 곡선이 성립한다 — 애니메이터를 늦추면 **히트(=데미지)도 같이 늦어진다**(별도 동기화 0).
// · 공격 판정 범위의 출처 = 투사체 프리팹의 BoxCollider 다(근접도 투사체를 쏜다). 실측:
// Mob/P_*_Meele 3×3×3 center 0 · Elite/P_Golem_A 3×3×3 center z 1.5 · FieldBoss/Anubis_Attack 2×2×4 center z 2
// → 데칼 크기를 코드 상수로 넣지 않고 **그 프리팹의 콜라이더를 읽어** 그린다 = 판정과 100 % 일치.
// · URP Decal Renderer Feature 가 없다(URP-Balanced-Renderer 의 피처 = SSAO 1개뿐) → DecalProjector 는 화면에 안 나온다.
// 기존 인디케이터 20종이 쓰는 **파티클/쿼드 방식**을 따른다(여기서는 런타임 쿼드 2장).
// · 보스(Anubis)는 코드가 3 s 차징을 이미 건다(Boss_Anubis.cs) → **보스는 건드리지 않는다**(bossUntouched).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Telegraph
{
/// <summary>기준서 §G-3 위험 색 규약 「제안」. 빨강 = 못 피한다(막기) · 노랑 = 피할 수 있다(회피).</summary>
public enum TelegraphDanger { Avoidable = 0, Unavoidable = 1 }
/// <summary>데칼이 「채워지는」 방향.</summary>
public enum TelegraphFill { ForwardSweep = 0, CenterExpand = 1 }
[CreateAssetMenu(fileName = "WLTelegraphSettings", menuName = "WL/Telegraph Settings", order = 326)]
public sealed class WLTelegraphSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLTelegraphSettings";
static WLTelegraphSettings s_cached;
static bool s_lookupDone;
public static WLTelegraphSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLTelegraphSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 예고만 끈다.</summary>
public static bool RuntimeDisabled;
public static bool Enabled { get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } }
/// <summary>811gh ImpactTier 가 읽는다 — 예고 중인 몹에게 히트스톱을 걸지 않는다(기준서 §G-4).</summary>
public static bool SkipHitStop { get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled && i.skipHitStopDuringTelegraph; } }
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 속도 곡선·데칼·펄스·색·SFX 가 전부 비활성 = 원본 동작 100 %(C8 롤백 1순위).")]
public bool enabled = true;
[Tooltip("예고 시작/종료를 Console 에 남긴다(검증용 · GC 발생).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── ⓐ 예고 시간 · 속도 곡선
[Header("ⓐ 예고 시간 (기준서 §G-6 표 · AttackStarted → HitboxSpawned 사이 목표 초)")]
[Tooltip("잡몹 근접 목표 예고 초. 기준서 §G-6 = 0.5 s(원본 실측 0.081~0.167).")]
public float telegraphSecondsNormal = 0.5f;
[Tooltip("잡몹 원거리(Batty_B/D 102·104) 목표 예고 초. 기준서 §G-6 = 0.6 s.")]
public float telegraphSecondsRanged = 0.6f;
[Tooltip("엘리트 목표 예고 초. 기준서 §G-6 = 0.8 s(첫 타만).")]
public float telegraphSecondsElite = 0.8f;
[Tooltip("🔴 보스는 건드리지 않는다 — 원본 코드가 이미 3 s 차징을 건다(Boss_Anubis.cs Skill1/2ChargingTime = 3).\n" +
"끄면 보스 평타에도 예고가 걸린다(발주 범위 밖 · 기본 켬 유지 권장).")]
public bool bossUntouched = true;
[Header("ⓐ 속도 곡선 (animator.speed = 히트시각 ÷ 목표 · 하한 있음)")]
[Tooltip("끄면 애니메이터 속도를 건드리지 않는다(데칼·펄스·색만 남는다).")]
public bool speedCurveEnabled = true;
[Tooltip("🔴 한계값. 원본 속도 대비 이 배수보다 더 느리게는 만들지 않는다.\n" +
"기준서 §G-2 ⓐ: k = 0.135 는 사실상 정지 화면이라 자연스러운 하한은 0.35 (Batty 예고 0.23 s).\n" +
"→ 속도 곡선 단독으로 0.5 s 는 안 된다. 데칼·펄스와 반드시 함께 쓴다.")]
public float minSpeedMultiplier = 0.35f;
[Tooltip("공격 클립의 히트 애니메이션 이벤트 함수명(이 이름의 이벤트 시각 = 원본 예고 시간).")]
public string[] hitEventNames = new[] { "Projectile", "BossProjectile2", "BossProjectile3" };
[Tooltip("클립에서 히트 이벤트를 못 찾았을 때 쓰는 기본 히트 시각(초). 기준서 실측 중앙값 ≈ 0.16.")]
public float defaultHitSeconds = 0.16f;
[Tooltip("첫 틱에서 실제 재생 중인 클립을 다시 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 두 번째 공격부터 정확).")]
public bool correctFromLiveClip = true;
[Tooltip("HitboxSpawned 가 이 시간 안에 안 오면 강제로 원복한다(피격·사망으로 공격이 끊긴 경우 · 초).")]
public float maxTelegraphSeconds = 2.5f;
// ─────────────────────────────────────────── ① 지면 데칼
[Header("① 지면 데칼 (투사체 BoxCollider 를 읽어 같은 크기 · 런타임 쿼드 2장 · 풀링)")]
[Tooltip("끄면 판을 그리지 않는다.")]
public bool decalEnabled = true;
[Tooltip("동시에 켤 수 있는 판의 최대 수. 초과하면 그 예고는 판을 생략한다(811s 예산기 방식 · 군중에서 화면이 뭉개지는 것 방지).")]
public int maxConcurrent = 6;
[Tooltip("풀 예열 개수(첫 전투에서의 순간 할당을 없앤다). maxConcurrent 이상 권장.")]
public int poolWarmCount = 8;
[Tooltip("몹 발밑 기준 판의 높이(지면 Z-파이팅 방지 · m).")]
public float groundYOffset = 0.06f;
[Tooltip("발사 위치 규약(Actor.Get_CenterPositionFoward 의 기본값)과 같은 전방 거리(m). 원본 = 0.3.")]
public float forwardOffset = 0.3f;
[Tooltip("판 크기 = 콜라이더 크기 × 이 배수. 1 = 판정과 100 % 일치(기본값 · 바꾸지 말 것 권장).")]
public float sizeScale = 1f;
[Tooltip("투사체 프리팹을 못 읽었을 때의 기본 판 크기(기준서 실측 잡몹 근접 = 3×3×3).")]
public Vector3 defaultBoxSize = new Vector3(3f, 3f, 3f);
[Tooltip("투사체 수명(f_ProjectileLifeTime1)이 이 값 이상이면 원거리로 보고 직선 판을 쓴다.\n" +
"실측: 근접 0.1 s · 원거리 1.5 s(Batty_B/D 102·104 · Mummy_King 1024).")]
public float rangedLifetimeThreshold = 0.5f;
[Tooltip("원거리 직선 판의 최대 길이(m). 길이 = 투사체 속도 × 수명 이지만 화면을 넘지 않게 자른다.")]
public float rangedMaxLength = 12f;
[Tooltip("🔴 투사체 프리팹에는 속도 필드가 없다(실측) — 실제 속도는 런타임 ProjectileData.Speed 기본값이다.\n" +
"원본 ProjectileBase.cs:19 실측 = 5. Projectile_Strait.m_Speed 가 0 보다 크면 프리팹 값이 이긴다.")]
public float defaultProjectileSpeed = 5f;
[Tooltip("몹이 회전하면 판도 따라 돈다(원본 Play_Attack 이 transform.LookAt 으로 계속 돌린다).")]
public bool followRotation = true;
[Tooltip("채워지는 방향. ForwardSweep = 몹 쪽에서 앞으로 채워진다 · CenterExpand = 가운데서 퍼진다.")]
public TelegraphFill fillMode = TelegraphFill.ForwardSweep;
[Tooltip("바깥 판(범위)의 알파.")]
[Range(0f, 1f)] public float plateAlpha = 0.28f;
[Tooltip("채워지는 안쪽 판의 알파.")]
[Range(0f, 1f)] public float fillAlpha = 0.62f;
[Tooltip("판 셰이더 후보(앞에서부터 Shader.Find · 새 셰이더/머티리얼 에셋 0 · 811gh DashAfterImage 와 같은 방식).")]
public string[] decalShaderNames = new[] { "Universal Render Pipeline/Unlit", "Unlit/Transparent", "Sprites/Default" };
[Tooltip("판 색 프로퍼티 후보(앞에서부터 FindPropertyIndex).")]
public string[] decalColorProperties = new[] { "_BaseColor", "_Color", "_TintColor" };
[Tooltip("판의 렌더 큐(반투명 · 3000 이상). 지면 위·캐릭터 아래로 보이게 한다.")]
public int decalRenderQueue = 3050;
[Header("① IndicatorInfo.Show_Indicator 재사용 (원본 공개 API · 로드/풀링/자동 끄기 완비)")]
[Tooltip("비어 있지 않으면 Res_Addr/Obj/<이름>.prefab 인디케이터를 같이 띄운다(IndicatorInfo.Make_Indicator 로 풀에서 꺼내 WL 이 위치·회전·수명을 직접 준다).\n" +
"🔴 기본값이 빈 문자열인 이유 = 보유 인디케이터 20종은 전부 **원형 파티클**이라 사각 콜라이더와 크기를 100 % 맞출 수 없다.\n" +
"판정 일치는 위의 쿼드가 담당하고, 이 항목은 「연출 덧칠」이다. 예: Golem_Child_Skill1_Indicator.")]
public string indicatorPrefabName = "";
[Tooltip("인디케이터의 파티클 시작 크기를 실측해 콜라이더 최대 변에 맞춰 스케일을 잡는다(끄면 스케일 1).")]
public bool indicatorAutoScale = true;
// ─────────────────────────────────────────── ③ 색 규약
[Header("③ 위험 색 규약 (기준서 §G-3 「제안」 · 빨강 = 못 피함/막기 · 노랑 = 회피 가능)")]
[Tooltip("회피 가능(노랑) 판 색.")]
[ColorUsage(true, true)] public Color colorAvoidable = new Color(1f, 0.82f, 0.15f, 1f);
[Tooltip("회피 불가(빨강) 판 색.")]
[ColorUsage(true, true)] public Color colorUnavoidable = new Color(1f, 0.16f, 0.12f, 1f);
[Tooltip("잡몹 근접의 위험 등급. 기준서 §G-6 = 노랑(회피 가능).")]
public TelegraphDanger dangerNormalMelee = TelegraphDanger.Avoidable;
[Tooltip("잡몹 원거리의 위험 등급. 기준서 §G-6 = 노랑(회피 가능).")]
public TelegraphDanger dangerNormalRanged = TelegraphDanger.Avoidable;
[Tooltip("엘리트의 위험 등급. 기준서 §G-6 엘리트 연속기 = 「데칼 + 색(노랑)」.\n" +
"🔴 「막아야 하는 공격」을 도입할 때 Unavoidable(빨강)로 올리면 된다 = 값 한 줄.")]
public TelegraphDanger dangerElite = TelegraphDanger.Avoidable;
// ─────────────────────────────────────────── ⓓ 몹 색/펄스
[Header("ⓓ 몹 틴트 (MobHitFlash MPB 경로 재사용 · 813s 와 같은 소유자 = 머티리얼 인스턴스 0)")]
[Tooltip("끄면 몹 색을 건드리지 않는다.")]
public bool tintEnabled = true;
[Tooltip("회피 가능(노랑) 예고 중 알베도 색.")]
[ColorUsage(true, true)] public Color tintAlbedoAvoidable = new Color(1.20f, 1.05f, 0.60f, 1f);
[Tooltip("회피 가능(노랑) 예고 중 에미션 색.")]
[ColorUsage(true, true)] public Color tintEmissionAvoidable = new Color(0.55f, 0.34f, 0.02f, 1f);
[Tooltip("회피 불가(빨강) 예고 중 알베도 색.")]
[ColorUsage(true, true)] public Color tintAlbedoUnavoidable = new Color(1.30f, 0.60f, 0.55f, 1f);
[Tooltip("회피 불가(빨강) 예고 중 에미션 색.")]
[ColorUsage(true, true)] public Color tintEmissionUnavoidable = new Color(0.75f, 0.08f, 0.04f, 1f);
[Tooltip("히트 직전 흰 섬광을 넣는다.")]
public bool flashEnabled = true;
[Tooltip("히트까지 남은 시간이 이 값 이하가 되면 흰 섬광으로 바꾼다(초 · 「직전」).")]
public float flashLeadSeconds = 0.06f;
[Tooltip("흰 섬광이 유지되는 시간(초 · 60 fps 기준 0.05 ≈ 3프레임).")]
public float flashSeconds = 0.05f;
[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);
[Header("ⓓ 스케일 펄스 (813s EliteMarker 와 같은 localScale 경로 · 원 스케일 복원)")]
[Tooltip("끄면 크기를 건드리지 않는다.")]
public bool pulseEnabled = true;
[Tooltip("예고 끝(히트)에서의 최대 배수. 기준서 §G-2 ⓓ = 1.12.")]
public float pulseScale = 1.12f;
[Tooltip("원복 뒤 이 시간 동안 원 스케일을 다시 확인한다(811b WLHitFeel 스케일 펀치와 겹칠 때 크기가 남는 것 방지 · 초).")]
public float pulseRestoreGuardSeconds = 0.2f;
// ─────────────────────────────────────────── ④ SFX · 히트스톱
[Header("④ 예고 SFX (보유 사운드 재사용 · 동시 상한)")]
[Tooltip("끄면 소리를 내지 않는다.")]
public bool sfxEnabled = true;
[Tooltip("eSound 인덱스. 9 = s009_CastSpell(짧은 캐스팅 음). 프로브가 SoundInfo.arr_clip 실존을 확인한다.")]
public int sfxIndex = 9;
[Tooltip("최대 볼륨(거리 감쇠는 원본 Play_OneShot_byDistance 가 한다).")]
[Range(0f, 1f)] public float sfxVolume = 0.35f;
[Tooltip("기준서 §G-4 「동시 3발 상한」 — 이 창 안에서 이 개수까지만 낸다(군중에서 소리가 뭉개지는 것 방지).")]
public int sfxMaxConcurrent = 3;
[Tooltip("동시 상한을 세는 창(초).")]
public float sfxWindowSeconds = 0.35f;
[Header("④ 811gh 히트스톱과의 관계 (기준서 §G-4)")]
[Tooltip("🔴 예고 중인 몹을 때렸을 때 히트스톱을 걸지 않는다.\n" +
"이유: hitStopMs 가 예고 구간에 겹치면 timeScale 이 바뀌어 **예고 길이가 흔들린다**.\n" +
"끄면 811gh 기존 동작 그대로(히트스톱 유지).")]
public bool skipHitStopDuringTelegraph = true;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26b2beddb0dd9fa498cecef15d5e4cdd