Compare commits

...

3 Commits

Author SHA1 Message Date
깃 관리자 2b1d106622 [WL-813g] 텍스트 연출 3종 + 슬롯 오버라이드 (#813) 2026-09-09 00:49:35 +09:00
깃 관리자 4c9005cde6 Merge branch 'wl/gameplay/WL-811def-reactions' into wl/ui/WL-813g-combat-text 2026-09-09 00:26:16 +09:00
깃 관리자 f4e58a4728 [WL-811def] 전투 연출 3종 — 킬캠·보스 캠 · 연쇄 처치 이벤트 · 몹 점멸 MPB (#813)
- 811d CombatCameraDirector(Killed→줌 0.75×+슬로모 Cinematic · Spawned(isBoss)→1.2 s 오빗 · ComboStage(isLast)→리드 ×1.3) · WLCombatCameraSettings.asset NEW · RealCamera 무수정(공개 필드만) · CameraMoveLead.LeadMultiplier 2줄
- 811e KillChain(3 s 윈도우 · DOUBLE/TRIPLE/RAMPAGE/MASSACRE 티어 이벤트 · 군집 폭발 BoomHit · EffectBudget.ClusterBurst ≤1) · DamageAggregator(0.25 s 합산 Flushed 이벤트) · WLReactionSettings.asset NEW · HUD 표시 0
- 811f MobHitFlash(HitConfirmed→MPB _AlbedoColor/_EmissionColor 0.03 s · Killed→디졸브 _EdgeAroundHDR/_EdgeAround · Spawned→정리 · arr_Renderer 리플렉션 캐시 · 머티리얼 대입 0) · WLHitBlinkSettings.asset NEW
- 프로브 AgentScripts/WL811def_Probe.cs(Title Play 가짜 이벤트 · Props 셰이더 실측 · GC · Toggle · Budget)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 00:20:16 +09:00
49 changed files with 6754 additions and 17 deletions

View File

@ -0,0 +1,585 @@
// WL811def_Probe.cs — #811d/e/f 전투 연출 3종 검증 프로브 (읽기 전용 · 에셋 무수정 · 가짜 이벤트 발행)
//
// 헤드리스에서는 PlayFab 초기화가 실패해 인게임에 못 들어간다(811b 실측) → Play 의존 검증은 **Title Play 에서 가짜 이벤트**로 대체한다.
// 가짜 = RealCamera 1개(빈 GO) · MobActor/PCActor 컴포넌트(비활성 · Awake 만 · 실 몹 머티리얼 T_Aspethis_D = MK Toon) · CombatEvents.Raise* 직접 호출.
//
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Dump // 3 SO 값 · 구독 상태 (Edit/Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Props // ⓔ 몹 프리팹 299 → 셰이더별 프로퍼티 실측 표 (Edit)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.KillCam // ⓑ 줌·슬로모 발동/복원 ms · 쿨 · 히트스톱 스킵 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.BossCam // ⓒ Spawned(isBoss) → 오빗 1.2 s (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.ComboLead // 3타 리드 ×1.3 → 복원 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Chain // ⓓ 3 s 안 2·3·5킬 티어 · 폭발 슬롯 ≤1 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Merge // ⓓ 합산 윈도우 0.25 s (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Blink // ⓔ 10회 피격 → MPB on/off 20 · 머티리얼 인스턴스 0 · 잔류 0 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.GcCheck // ⓕ 구독자별 100회 GC 증가 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Toggle // ⓖ 3 SO 런타임 off → 연출 0 (Title Play)
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Status // 카운터 전부
// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Budget --args '[5.0]' // 병합 후 실전투(메인 에디터): ON/OFF fps·setPass 비교
// 결과: Screenshots_WL/WL811def/<이름>_<HHmmss>.txt + Console
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.AI;
using WL.Combat.Core;
using WL.Combat.Reaction;
using WL.CombatCamera;
public static class WL811def_Probe
{
const string OutDir = "Screenshots_WL/WL811def";
const string SampleMat = "Assets/ResWork/Suriyun/Monster Pack Pyramid/Materials/Aspethis/T_Aspethis_D.mat";
const string DissolveMat = "Assets/ThirdParty/Dissolve Edge by Moonflower Carnivore/Materials/mat_DissolveEdge.mat";
public static void KillCam() { Launch(h => h.Co_KillCam()); }
public static void BossCam() { Launch(h => h.Co_BossCam()); }
public static void ComboLead() { Launch(h => h.Co_ComboLead()); }
public static void Chain() { Launch(h => h.Co_Chain()); }
public static void Merge() { Launch(h => h.Co_Merge()); }
public static void Blink() { Launch(h => h.Co_Blink()); }
public static void GcCheck() { Launch(h => h.Co_Gc()); }
public static void Toggle() { Launch(h => h.Co_Toggle()); }
public static void Budget(float seconds) { Launch(h => h.Co_Budget(seconds <= 0f ? 5f : seconds)); }
public static object Dump()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811def Dump " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled);
var cam = WLCombatCameraSettings.Instance; var re = WLReactionSettings.Instance; var bl = WLHitBlinkSettings.Instance;
sb.AppendLine("camera asset=" + (cam != null ? "found" : "NULL") + " Enabled=" + WLCombatCameraSettings.Enabled + " subscribed=" + CombatCameraDirector.Subscribed);
sb.AppendLine("reaction asset=" + (re != null ? "found" : "NULL") + " Enabled=" + WLReactionSettings.Enabled + " chainSub=" + KillChain.Subscribed + " mergeSub=" + DamageAggregator.Subscribed);
sb.AppendLine("blink asset=" + (bl != null ? "found" : "NULL") + " Enabled=" + WLHitBlinkSettings.Enabled + " subscribed=" + MobHitFlash.Subscribed);
sb.AppendLine("subs: Killed=" + CombatEvents.Killed.Count + " Spawned=" + CombatEvents.Spawned.Count + " ComboStage=" + CombatEvents.ComboStage.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count);
var core = WLCombatCoreSettings.Instance;
if (core != null) sb.AppendLine("core: cinematicDefaultScale=" + core.cinematicDefaultScale + " killCamCooldownSeconds=" + core.killCamCooldownSeconds + " clusterBurstMax=" + core.clusterBurstMax + " dissolveMax=" + core.dissolveMax);
if (cam != null) sb.AppendLine("## WLCombatCameraSettings\n" + JsonUtility.ToJson(cam, true));
if (re != null) sb.AppendLine("## WLReactionSettings\n" + JsonUtility.ToJson(re, true));
if (bl != null) sb.AppendLine("## WLHitBlinkSettings\n" + JsonUtility.ToJson(bl, true));
Save("dump", sb); return sb.ToString();
}
/// <summary>ⓔ 몹 프리팹 전수(Res_Addr/Mobs) → 비파티클 렌더러의 셰이더별 프로퍼티 존재(Shader.FindPropertyIndex) 표. Edit 모드.</summary>
public static object Props()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811def Props " + DateTime.Now.ToString("HH:mm:ss"));
string[] names = { "_AlbedoColor", "_BaseColor", "_Color", "_EmissionColor", "_EdgeAroundHDR", "_EdgeAround", "_Progress" };
var byShader = new Dictionary<string, int[]>(); // [rendererCount, prefabCount, prop0..]
var lastPrefab = new Dictionary<string, string>();
int prefabs = 0, renderers = 0, particle = 0;
foreach (var folder in new[] { "Assets/Res_Addr/Mobs/Mob", "Assets/Res_Addr/Mobs/Elite", "Assets/Res_Addr/Mobs/FieldBoss" })
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new[] { folder });
foreach (var g in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(g);
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) continue;
prefabs++;
var seen = new HashSet<string>();
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
{
if (r is ParticleSystemRenderer) { particle++; continue; }
renderers++;
var m = r.sharedMaterial; var sh = m != null ? m.shader : null;
string key = sh != null ? sh.name : "(null shader)";
int[] row;
if (!byShader.TryGetValue(key, out row))
{
row = new int[2 + names.Length];
if (sh != null) for (int i = 0; i < names.Length; i++) row[2 + i] = sh.FindPropertyIndex(names[i]) >= 0 ? 1 : 0;
byShader[key] = row;
}
row[0]++;
if (seen.Add(key)) { row[1]++; lastPrefab[key] = System.IO.Path.GetFileNameWithoutExtension(path); }
}
}
}
sb.AppendLine("prefabs=" + prefabs + " renderers(non-particle)=" + renderers + " particleRenderers(skipped)=" + particle);
sb.AppendLine(string.Format("{0,-45} {1,5} {2,5} | {3}", "shader", "rend", "pref", string.Join(" ", names)));
foreach (var kv in byShader)
{
var row = kv.Value; var cols = new StringBuilder();
for (int i = 0; i < names.Length; i++) cols.Append(string.Format("{0,-" + names[i].Length + "} ", row[2 + i] == 1 ? "Y" : "-"));
sb.AppendLine(string.Format("{0,-45} {1,5} {2,5} | {3} e.g. {4}", kv.Key, row[0], row[1], cols.ToString().TrimEnd(), lastPrefab[kv.Key]));
}
var dis = UnityEditor.AssetDatabase.LoadAssetAtPath<Material>(DissolveMat);
if (dis != null && dis.shader != null)
{
var cols = new StringBuilder();
for (int i = 0; i < names.Length; i++) cols.Append(names[i] + "=" + (dis.shader.FindPropertyIndex(names[i]) >= 0 ? "Y" : "-") + " ");
sb.AppendLine("dissolve mat=" + dis.name + " shader=" + dis.shader.name + " | " + cols + "| _EdgeAroundHDR=" + dis.GetFloat("_EdgeAroundHDR") + " _EdgeAround=" + dis.GetFloat("_EdgeAround"));
}
var found = Shader.Find(WLHitBlinkSettings.Instance != null ? WLHitBlinkSettings.Instance.dissolveShaderName : "MoonflowerCarnivore/Dissolve Edge");
sb.AppendLine("Shader.Find(dissolveShaderName)=" + (found != null ? found.name : "NULL"));
Save("props", sb); return sb.ToString();
}
public static object Status()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811def Status " + DateTime.Now.ToString("HH:mm:ss") + " timeScale=" + Time.timeScale.ToString("F2") + " arbiter=" + TimeScaleArbiter.Active);
sb.AppendLine("camera: killCam=" + CombatCameraDirector.KillCamCount + " skip(role/cool/runcap/guard/nocam/busy)=" + CombatCameraDirector.KillCamSkippedRole + "/" + CombatCameraDirector.KillCamSkippedCooldown + "/" + CombatCameraDirector.KillCamSkippedRunCap + "/" + CombatCameraDirector.KillCamSkippedGuard + "/" + CombatCameraDirector.KillCamSkippedNoCam + "/" + CombatCameraDirector.KillCamSkippedBusy
+ " abandoned=" + CombatCameraDirector.KillCamAbandoned + " boss=" + CombatCameraDirector.BossCamCount + " bossSkip=" + CombatCameraDirector.BossCamSkipped + " preset=" + CombatCameraDirector.BossPresetApplied + " lead=" + CombatCameraDirector.ComboLeadCount
+ " active(kill/boss)=" + CombatCameraDirector.KillCamActive + "/" + CombatCameraDirector.BossCamActive + " zoom=" + CombatCameraDirector.CurrentZoomScale.ToString("F3") + " lastKillCam=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3") + " lastBoss=" + CombatCameraDirector.LastBossCamMeasured.ToString("F3") + " slowmo=" + CombatCameraDirector.LastSlowMoResult + " run=" + CombatCameraDirector.RunCount + " last=" + CombatCameraDirector.LastInfo);
sb.AppendLine("chain: kills=" + KillChain.KillCount + " tiers=" + KillChain.TierCount + " chains=" + KillChain.ChainCount + " best=" + KillChain.BestChainEver + " cur=" + KillChain.ChainKills + " burst=" + KillChain.BurstCount + " denied=" + KillChain.BurstDeniedBudget + " outOfView=" + KillChain.BurstSkippedView + " noInfo=" + KillChain.BurstNoInGameInfo + " held=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + "/" + EffectBudget.Limit(EffectBudgetKind.ClusterBurst) + " last=" + KillChain.LastInfo);
sb.AppendLine("merge: hits=" + DamageAggregator.HitCount + " flush=" + DamageAggregator.FlushCount + " maxMerged=" + DamageAggregator.MaxHitsMerged + " evict=" + DamageAggregator.EvictCount + " active=" + DamageAggregator.ActiveSlots);
sb.AppendLine("blink: on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " extend=" + MobHitFlash.ExtendCount + " throttled=" + MobHitFlash.ThrottledCount + " tint=" + MobHitFlash.TintCount + " clear=" + MobHitFlash.ClearCount + " skipFilter=" + MobHitFlash.SkippedFilter + " noProp=" + MobHitFlash.SkippedNoProperty + " states=" + MobHitFlash.StateCount + " active=" + MobHitFlash.ActiveCount + " reflect=" + MobHitFlash.RendererFieldViaReflection + " dissolve(resolved/hdr/range)=" + MobHitFlash.DissolveResolved + "/" + MobHitFlash.DissolveHasHdr + "/" + MobHitFlash.DissolveHasRange);
sb.AppendLine("lead multiplier=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " runner(reaction)=" + WLReactionRunner.Exists);
Save("status", sb); return sb.ToString();
}
// ───────────────────────────────────────────── 공용
static void Save(string name, StringBuilder sb)
{
try
{
System.IO.Directory.CreateDirectory(OutDir);
string path = System.IO.Path.Combine(OutDir, name + "_" + DateTime.Now.ToString("HHmmss") + ".txt");
System.IO.File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL811def] 저장 " + path);
}
catch (Exception e) { Debug.LogWarning("[WL811def] 저장 실패 " + e.Message); }
Debug.Log(sb.ToString());
}
static void Launch(Func<Host, IEnumerator> make)
{
if (!Application.isPlaying) { Debug.LogWarning("[WL811def] Play 모드에서만(Title Play). Edit 모드는 Dump/Props/Status 만."); return; }
var old = GameObject.Find("__wl811def_probe");
if (old != null) UnityEngine.Object.DestroyImmediate(old);
var go = new GameObject("__wl811def_probe");
var h = go.AddComponent<Host>();
h.StartCoroutine(make(h));
}
/// <summary>가짜 몹: 비활성 GO 에 컴포넌트를 얹고(NavMeshAgent 는 비활성 → 네비메시 에러 0) 활성화해 Awake(arr_Renderer 캐시)만 돌린다. Start/Update 없음.</summary>
static MobActor MakeMob(string name, eSubRol sub, Vector3 pos, Material mat)
{
var go = new GameObject(name);
go.SetActive(false);
go.transform.position = pos;
go.AddComponent<Rigidbody>().isKinematic = true;
go.AddComponent<BoxCollider>();
go.AddComponent<Animator>();
var agent = go.AddComponent<NavMeshAgent>(); agent.enabled = false;
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.name = "Body"; cube.transform.SetParent(go.transform, false);
UnityEngine.Object.Destroy(cube.GetComponent<Collider>());
if (mat != null) cube.GetComponent<MeshRenderer>().sharedMaterial = mat;
var mob = go.AddComponent<MobActor>(); mob.enabled = false;
mob.m_SubRole = sub;
go.SetActive(true);
return mob;
}
static PCActor MakePC(string name, Vector3 pos)
{
var go = new GameObject(name);
go.SetActive(false);
go.transform.position = pos;
go.AddComponent<Rigidbody>().isKinematic = true;
go.AddComponent<BoxCollider>();
go.AddComponent<Animator>();
var agent = go.AddComponent<NavMeshAgent>(); agent.enabled = false;
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.SetParent(go.transform, false);
UnityEngine.Object.Destroy(cube.GetComponent<Collider>());
var pc = go.AddComponent<PCActor>(); pc.enabled = false;
go.SetActive(true);
return pc;
}
static Material LoadSampleMat()
{
return UnityEditor.AssetDatabase.LoadAssetAtPath<Material>(SampleMat); // 에디터 전용 프로브(Roslyn run_script 에 UNITY_EDITOR 심볼이 없을 수 있어 #if 미사용)
}
static DamageInfo MakeDamage(double dmg, bool cri, bool kill)
{
var d = new DamageInfo { Beater = null, Critical = cri ? eStat.CRI : eStat.None, IsDeadByThisDamage = kill };
d.Damage = dmg; d.BaseDmg = dmg;
return d;
}
public class Host : MonoBehaviour
{
StringBuilder _sb; float _t0;
RealCamera _cam; Transform _dummy; bool _camInstalled; RealCamera _prevCam;
readonly List<GameObject> _trash = new List<GameObject>();
string Pre() { return string.Format("{0,7:F0} | f{1,-5} | ts={2:F2} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount, Time.timeScale); }
static string N(UnityEngine.Object o) { return o != null ? o.name : "null"; }
IEnumerator Wait(float s) { float t = Time.unscaledTime; while (Time.unscaledTime - t < s) yield return null; }
Vector3 Front(float dist)
{
var c = Camera.main;
return c != null ? c.transform.position + c.transform.forward * dist : Vector3.zero;
}
/// <summary>가짜 RealCamera(빈 GO · 카메라 컴포넌트 없음): 원본 프리팹 [0] 값(3/3/2/78 → 기준서 65) · CamStatus 기본 1 · Set_Target(1인자)로 Update_Cam 만 돈다.</summary>
void InstallCamera()
{
_prevCam = MyValue._RealCamera;
var go = new GameObject("__wl811def_cam"); _trash.Add(go);
_cam = go.AddComponent<RealCamera>();
_cam.arr_cCamData = new[] { new cCamData { minDistance = 3f, maxDistance = 3f, DistanceUp = 2f, cameraHeight = 65f }, new cCamData { minDistance = 2f, maxDistance = 3f, DistanceUp = 3.5f, cameraHeight = 78f } };
_cam.minDistance = 3f; _cam.maxDistance = 3f; _cam.DistanceUp = 2f; _cam.cameraHeight = 65f; _cam.rotateAround = 30f; _cam.smooth = 4f;
var d = new GameObject("__wl811def_pc"); _trash.Add(d); d.transform.position = Front(6f);
_dummy = d.transform;
_cam.Set_Target(_dummy);
MyValue._RealCamera = _cam; _camInstalled = true;
}
void Cleanup()
{
CombatCameraDirector.ReleaseAll();
MobHitFlash.RestoreAll();
KillChain.ClearAll(); DamageAggregator.ClearAll();
if (_camInstalled) { MyValue._RealCamera = _prevCam; _camInstalled = false; }
TimeScaleArbiter.ReleaseAll(); Time.timeScale = 1f;
for (int i = 0; i < _trash.Count; i++) if (_trash[i] != null) Destroy(_trash[i]);
_trash.Clear();
Destroy(gameObject);
}
string CamRow() { return "min=" + _cam.minDistance.ToString("F3") + " max=" + _cam.maxDistance.ToString("F3") + " up=" + _cam.DistanceUp.ToString("F3") + " yaw=" + _cam.rotateAround.ToString("F2") + " target=" + N(_cam.target) + " zoom=" + CombatCameraDirector.CurrentZoomScale.ToString("F3") + " arb=" + TimeScaleArbiter.Active + " camPos=" + _cam.transform.position.ToString("F2"); }
/// <summary>ⓑ 킬캠: 엘리트 Killed → 줌(3→2.25→3)·슬로모(0.35→1) 타임라인 · 히트스톱 SkippedHigher · 쿨 안 재발동 0 · ResetRun 뒤 재발동 · 일반 몹 = 스킵.</summary>
public IEnumerator Co_KillCam()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
_sb.AppendLine("# WL811def KillCam " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCameraSettings.Enabled + " core=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled);
var st = WLCombatCameraSettings.Instance; var core = WLCombatCoreSettings.Instance;
_sb.AppendLine("# 값: zoom=" + st.killCamZoomScale + " in/hold/out=" + st.killCamZoomInSeconds + "/" + st.killCamHoldSeconds + "/" + st.killCamZoomOutSeconds + " slowmo=" + core.cinematicDefaultScale + "×" + st.killCamSlowMoSeconds + " s cool=" + core.killCamCooldownSeconds + " guardY=" + st.killCamBottomGuardViewportY + " maxPerRun=" + st.killCamMaxPerRun);
CombatCameraDirector.ResetDiagnostics(); CombatCameraDirector.ResetRun(); TimeScaleArbiter.ResetDiagnostics();
InstallCamera();
yield return null; yield return null;
var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), null); _trash.Add(elite.gameObject);
var normal = MakeMob("__wl811def_normal", eSubRol.None, Front(5f), null); _trash.Add(normal.gameObject);
yield return null;
_sb.AppendLine(Pre() + "before " + CamRow());
// ① 일반 몹 → 역할 스킵
CombatEvents.RaiseKilled(normal);
_sb.AppendLine(Pre() + "① Normal Killed → killCam=" + CombatCameraDirector.KillCamCount + " skipRole=" + CombatCameraDirector.KillCamSkippedRole + " (기대 0 / 1)");
// ② 엘리트 → 발동 · 타임라인
float minSeen = 99f, tsMin = 9f; float zoomReach = -1f, tsRestore = -1f, camRestore = -1f;
float tStart = Time.unscaledTime;
CombatEvents.RaiseKilled(elite);
_sb.AppendLine(Pre() + "② Elite Killed → killCam=" + CombatCameraDirector.KillCamCount + " slowmo=" + CombatCameraDirector.LastSlowMoResult + " viewportY=" + CombatCameraDirector.LastVictimViewportY.ToString("F2") + " last=" + CombatCameraDirector.LastInfo + " " + CamRow());
bool hsTried = false; ArbiterResult hs = ArbiterResult.Invalid;
float tEnd = tStart + st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.4f;
int rows = 0;
while (Time.unscaledTime < tEnd)
{
yield return null;
float t = Time.unscaledTime - tStart;
if (!hsTried && t > 0.08f) { hs = TimeScaleArbiter.Request(TimeLayer.HitStop, 0f, 0.04f); hsTried = true; _sb.AppendLine(Pre() + " 슬로모 중 HitStop 요청 → " + hs + " (" + TimeScaleArbiter.LastInfo + ") 기대=SkippedHigher"); }
minSeen = Mathf.Min(minSeen, _cam.minDistance); tsMin = Mathf.Min(tsMin, Time.timeScale);
if (zoomReach < 0f && Mathf.Approximately(_cam.minDistance, 3f * st.killCamZoomScale)) zoomReach = t;
if (tsRestore < 0f && tsMin < 0.99f && Time.timeScale >= 0.999f) tsRestore = t;
if (camRestore < 0f && zoomReach >= 0f && !CombatCameraDirector.KillCamActive) camRestore = t;
if ((rows++ % 4) == 0 || !CombatCameraDirector.KillCamActive) _sb.AppendLine(Pre() + " " + CamRow());
if (camRestore >= 0f && tsRestore >= 0f && t > 0.9f) break;
}
_sb.AppendLine(Pre() + " 결과: minDistance 최소=" + minSeen.ToString("F3") + " (기대 " + (3f * st.killCamZoomScale).ToString("F3") + ") 줌 도달 " + (zoomReach * 1000f).ToString("F0") + " ms · timeScale 최소=" + tsMin.ToString("F2") + " 복원 " + (tsRestore * 1000f).ToString("F0") + " ms(기대 ≈" + (st.killCamSlowMoSeconds * 1000f).ToString("F0") + ") · 카메라 복원 " + (camRestore * 1000f).ToString("F0") + " ms · measured=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3") + " s · abandoned=" + CombatCameraDirector.KillCamAbandoned + " · 종료 " + CamRow() + " arbiterMeasured=" + TimeScaleArbiter.LastMeasured.ToString("F3"));
// ③ 쿨 안 재발동 → 스킵
CombatEvents.RaiseKilled(elite);
_sb.AppendLine(Pre() + "③ 쿨 안 Elite Killed → killCam=" + CombatCameraDirector.KillCamCount + " skipCooldown=" + CombatCameraDirector.KillCamSkippedCooldown + " (기대 1 / 1) last=" + CombatCameraDirector.LastInfo);
// ④ ResetRun(쿨·런 초기화) → 재발동 · 보스 킬
var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), null); _trash.Add(boss.gameObject);
CombatCameraDirector.ResetRun();
CombatEvents.RaiseKilled(boss);
_sb.AppendLine(Pre() + "④ ResetRun 후 Boss Killed → killCam=" + CombatCameraDirector.KillCamCount + " (기대 2) slowmo=" + CombatCameraDirector.LastSlowMoResult + " " + CamRow());
yield return Wait(st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.3f);
_sb.AppendLine(Pre() + " 복원 확인 " + CamRow() + " active=" + CombatCameraDirector.KillCamActive + " measured=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3"));
// ⑤ 런 상한: ResetRun 뒤 쿨만 매번 지우며(ResetCooldown) maxPerRun 회 발동 → 다음은 runcap 스킵
int cap = st.killCamMaxPerRun;
if (cap > 0)
{
CombatCameraDirector.ResetRun();
int before = CombatCameraDirector.KillCamCount, cool0 = CombatCameraDirector.KillCamSkippedCooldown;
for (int i = 0; i < cap; i++) { CombatCameraDirector.ResetCooldown(); CombatEvents.RaiseKilled(elite); CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null; }
CombatCameraDirector.ResetCooldown();
CombatEvents.RaiseKilled(elite);
_sb.AppendLine(Pre() + "⑤ 런 상한 " + cap + ": 발동+" + (CombatCameraDirector.KillCamCount - before) + " (기대 " + cap + ") skipRunCap=" + CombatCameraDirector.KillCamSkippedRunCap + " (기대 1) skipCool+" + (CombatCameraDirector.KillCamSkippedCooldown - cool0) + " (기대 0) run=" + CombatCameraDirector.RunCount);
}
_sb.AppendLine("# 종료 timeScale=" + Time.timeScale.ToString("F2") + " arbiter=" + TimeScaleArbiter.Active + " " + CamRow());
Save("killcam", _sb); Cleanup();
}
/// <summary>ⓒ 보스 캠: Spawned(isBoss) → Set_Target(보스) · sin 오빗 · 거리 배율 → 1.2 s 뒤 전부 복원 · arr_cCamData[1] 프리셋.</summary>
public IEnumerator Co_BossCam()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
var st = WLCombatCameraSettings.Instance;
_sb.AppendLine("# WL811def BossCam " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCameraSettings.Enabled + " sec=" + st.bossCamSeconds + " orbit=" + st.bossCamOrbitDegrees + "° dist×" + st.bossCamDistanceScale + " retarget=" + st.bossCamRetarget + " preset=" + st.bossPresetApply + "(" + st.bossPresetDistance + " m · " + st.bossPresetCameraHeight + "°)");
CombatCameraDirector.ResetDiagnostics();
InstallCamera();
yield return null; yield return null;
var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f) + Vector3.right * 2f, null); _trash.Add(boss.gameObject);
var normal = MakeMob("__wl811def_normal", eSubRol.None, Front(5f), null); _trash.Add(normal.gameObject);
yield return null;
float yaw0 = _cam.rotateAround; var preset0 = _cam.arr_cCamData[1];
_sb.AppendLine(Pre() + "before " + CamRow() + " preset[1]=" + preset0.minDistance + "/" + preset0.maxDistance + "/" + preset0.cameraHeight + "°");
CombatEvents.RaiseSpawned(normal);
_sb.AppendLine(Pre() + "① Normal Spawned → boss=" + CombatCameraDirector.BossCamCount + " (기대 0)");
float tStart = Time.unscaledTime; float yawMax = -999f, yawMin = 999f; bool sawBossTarget = false;
CombatEvents.RaiseSpawned(boss);
_sb.AppendLine(Pre() + "② Boss Spawned → boss=" + CombatCameraDirector.BossCamCount + " active=" + CombatCameraDirector.BossCamActive + " preset[1]=" + preset0.minDistance + "/" + preset0.maxDistance + "/" + preset0.cameraHeight + "° up=" + preset0.DistanceUp + " " + CamRow());
int rows = 0; float tRestore = -1f;
while (Time.unscaledTime - tStart < st.bossCamSeconds + 0.5f)
{
yield return null;
float rel = Mathf.DeltaAngle(yaw0, _cam.rotateAround);
yawMax = Mathf.Max(yawMax, rel); yawMin = Mathf.Min(yawMin, rel);
if (ReferenceEquals(_cam.target, boss.transform)) sawBossTarget = true;
if (tRestore < 0f && !CombatCameraDirector.BossCamActive) tRestore = Time.unscaledTime - tStart;
if ((rows++ % 6) == 0 || tRestore >= 0f && rows % 6 == 1) _sb.AppendLine(Pre() + " yawRel=" + rel.ToString("F2") + " " + CamRow());
if (tRestore >= 0f && Time.unscaledTime - tStart > st.bossCamSeconds + 0.2f) break;
}
_sb.AppendLine(Pre() + " 결과: yaw 진폭 max=" + yawMax.ToString("F2") + " min=" + yawMin.ToString("F2") + " (기대 ≈+" + st.bossCamOrbitDegrees + ") · 종료 yawRel=" + Mathf.DeltaAngle(yaw0, _cam.rotateAround).ToString("F3") + " (기대 0) · 보스 타겟 관측=" + sawBossTarget + " · 복원 " + (tRestore * 1000f).ToString("F0") + " ms(기대 ≈" + (st.bossCamSeconds * 1000f).ToString("F0") + ") · measured=" + CombatCameraDirector.LastBossCamMeasured.ToString("F3") + " · target 복원=" + ReferenceEquals(_cam.target, _dummy) + " · " + CamRow());
Save("bosscam", _sb); Cleanup();
}
/// <summary>3타 리드: ComboStage(isLast) → LeadMultiplier 1.3 → comboLeadSeconds 뒤 1.</summary>
public IEnumerator Co_ComboLead()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
var st = WLCombatCameraSettings.Instance;
_sb.AppendLine("# WL811def ComboLead " + DateTime.Now.ToString("HH:mm:ss") + " ×" + st.comboLeadMultiplier + " " + st.comboLeadSeconds + " s");
var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject);
yield return null;
CombatEvents.RaiseComboStage(pc, 2, "Attack2_S", 0f, false);
_sb.AppendLine(Pre() + "① stage2(isLast=false) → mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " (기대 1.00) count=" + CombatCameraDirector.ComboLeadCount);
float t0 = Time.unscaledTime;
CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true);
_sb.AppendLine(Pre() + "② stage3(isLast) → mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " (기대 " + st.comboLeadMultiplier.ToString("F2") + ") count=" + CombatCameraDirector.ComboLeadCount);
float tBack = -1f;
while (Time.unscaledTime - t0 < st.comboLeadSeconds + 0.3f) { yield return null; if (tBack < 0f && Mathf.Approximately(CameraMoveLead.LeadMultiplier, 1f)) tBack = Time.unscaledTime - t0; }
_sb.AppendLine(Pre() + " 복원 " + (tBack * 1000f).ToString("F0") + " ms (기대 ≈" + (st.comboLeadSeconds * 1000f).ToString("F0") + ") mult=" + CameraMoveLead.LeadMultiplier.ToString("F2"));
Save("combolead", _sb); Cleanup();
}
int _tier, _ended; string _tierLog = "";
void OnTier(in KillChainTierEvent e) { _tier++; _tierLog += Pre() + " TierReached " + e.name + " tier=" + e.tier + " kills=" + e.kills + " centroid=" + e.centroid.ToString("F1") + " burst=" + e.burstSpawned + "\n"; }
void OnEnded(in KillChainEndedEvent e) { _ended++; _tierLog += Pre() + " Ended kills=" + e.kills + " best=" + e.bestTier + " dur=" + e.duration.ToString("F2") + "\n"; }
/// <summary>ⓓ 연쇄: 3 s 안 3킬 → DOUBLE·TRIPLE · 폭발 슬롯 1(홀드) · 윈도우 만료 Ended · 5킬 RAMPAGE · 홀드 중 폭발 거부.</summary>
public IEnumerator Co_Chain()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime; _tier = 0; _ended = 0; _tierLog = "";
var st = WLReactionSettings.Instance;
_sb.AppendLine("# WL811def Chain " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLReactionSettings.Enabled + " window=" + st.killChainWindowSeconds + " burstMinTier=" + st.clusterBurstMinTier + " prefab=" + st.clusterBurstPrefab + " hold=" + st.clusterBurstHoldSeconds + " InGameInfo=" + (InGameInfo.Ins != null));
KillChain.ResetDiagnostics(); KillChain.ClearAll(); EffectBudget.ResetAll();
KillChain.TierReached.Add(OnTier); KillChain.Ended.Add(OnEnded);
var mobs = new MobActor[5];
for (int i = 0; i < 5; i++) { mobs[i] = MakeMob("__wl811def_mob" + i, eSubRol.None, Front(5f) + Vector3.right * (i - 2), null); _trash.Add(mobs[i].gameObject); }
yield return null;
for (int i = 0; i < 3; i++) { CombatEvents.RaiseKilled(mobs[i]); yield return Wait(0.15f); }
_sb.AppendLine(Pre() + "① 0.45 s 안 3킬 → tiers=" + _tier + " (기대 2: DOUBLE·TRIPLE) chain=" + KillChain.ChainKills + " burstHeld=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + " burst/noInfo/denied=" + KillChain.BurstCount + "/" + KillChain.BurstNoInGameInfo + "/" + KillChain.BurstDeniedBudget);
_sb.Append(_tierLog); _tierLog = "";
yield return Wait(st.clusterBurstHoldSeconds + 0.1f);
_sb.AppendLine(Pre() + " 홀드 만료 → burstHeld=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + " (기대 false/0)");
yield return Wait(st.killChainWindowSeconds - st.clusterBurstHoldSeconds);
_sb.AppendLine(Pre() + "② 윈도우 만료 → ended=" + _ended + " (기대 1) chain=" + KillChain.ChainKills + " (기대 0)");
_sb.Append(_tierLog); _tierLog = "";
int denied0 = KillChain.BurstDeniedBudget;
for (int i = 0; i < 5; i++) { CombatEvents.RaiseKilled(mobs[i]); yield return null; }
_sb.AppendLine(Pre() + "③ 5킬 연속 → tiers+=" + _tier + " (누적 기대 5) best=" + KillChain.BestChainEver + " burstDenied+" + (KillChain.BurstDeniedBudget - denied0) + " (TRIPLE 슬롯 홀드 중 RAMPAGE 폭발 거부 = 동시 ≤1) budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst));
_sb.Append(_tierLog);
KillChain.TierReached.Remove(OnTier); KillChain.Ended.Remove(OnEnded);
Save("chain", _sb); Cleanup();
}
int _flush; string _flushLog = "";
void OnFlush(in DamageMergeEvent e) { _flush++; _flushLog += Pre() + " Flushed victim=" + N(e.victim) + " total=" + e.total.ToString("F0") + " hits=" + e.hits + " max=" + e.maxSingle.ToString("F0") + " cri=" + e.anyCritical + " kill=" + e.isKill + " window=" + ((e.time - e.firstTime) * 1000f).ToString("F0") + " ms\n"; }
/// <summary>ⓓ 합산: 0.25 s 안 3타(10+20+30 · 2타 크리) → 플러시 1(60 · 3타) · 처치 타격 → 즉시 플러시.</summary>
public IEnumerator Co_Merge()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime; _flush = 0; _flushLog = "";
var st = WLReactionSettings.Instance;
_sb.AppendLine("# WL811def Merge " + DateTime.Now.ToString("HH:mm:ss") + " window=" + st.damageMergeWindowSeconds + " flushOnKill=" + st.damageMergeFlushOnKill);
DamageAggregator.ResetDiagnostics(); DamageAggregator.ClearAll();
DamageAggregator.Flushed.Add(OnFlush);
var mob = MakeMob("__wl811def_mob", eSubRol.None, Front(5f), null); _trash.Add(mob.gameObject);
yield return null;
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)); yield return null;
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(20, true, false)); yield return null;
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(30, false, false));
double pend; int ph; bool has = DamageAggregator.TryGetPending(mob, out pend, out ph);
_sb.AppendLine(Pre() + "① 3타 → pending=" + has + " total=" + pend.ToString("F0") + " hits=" + ph + " flush=" + _flush + " (기대 true/60/3/0)");
yield return Wait(st.damageMergeWindowSeconds + 0.1f);
_sb.AppendLine(Pre() + " 윈도우 만료 → flush=" + _flush + " (기대 1) active=" + DamageAggregator.ActiveSlots);
_sb.Append(_flushLog); _flushLog = "";
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(5, false, false)); yield return null;
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(999, false, true));
_sb.AppendLine(Pre() + "② 처치 타격 → 즉시 flush=" + _flush + " (기대 2) active=" + DamageAggregator.ActiveSlots);
_sb.Append(_flushLog);
DamageAggregator.Flushed.Remove(OnFlush);
Save("merge", _sb); Cleanup();
}
/// <summary>ⓔ 점멸: MK Toon 실 머티리얼 큐브 몹 10회 피격(0.1 s 간격) → on/off 20 · HasPropertyBlock 잔류 0 · 머티리얼 수 증가 0 · sharedMaterial 불변 · Killed 엣지 → Spawned 정리.</summary>
public IEnumerator Co_Blink()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
var st = WLHitBlinkSettings.Instance;
_sb.AppendLine("# WL811def Blink " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLHitBlinkSettings.Enabled + " on/off=" + st.blinkOnSeconds + "/" + st.blinkOffSeconds + " color=" + st.blinkColor + " emission=" + st.useEmission);
MobHitFlash.ResetDiagnostics();
var mat = LoadSampleMat();
_sb.AppendLine("# 머티리얼 " + (mat != null ? mat.name + " shader=" + mat.shader.name : "NULL(기본 큐브 머티리얼)"));
var mob = MakeMob("__wl811def_mob", eSubRol.Elite, Front(5f), mat); _trash.Add(mob.gameObject);
yield return null;
var rend = mob.GetComponentInChildren<MeshRenderer>();
int matsBefore = Resources.FindObjectsOfTypeAll<Material>().Length;
int sharedId = rend.sharedMaterial != null ? rend.sharedMaterial.GetInstanceID() : 0;
_sb.AppendLine(Pre() + "before block=" + rend.HasPropertyBlock() + " materials=" + matsBefore + " sharedId=" + sharedId);
int onDuringSeen = 0;
for (int i = 0; i < 10; i++)
{
CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false));
bool hasNow = rend.HasPropertyBlock();
if (hasNow) onDuringSeen++;
if (i < 3) _sb.AppendLine(Pre() + " hit" + (i + 1) + " on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " block=" + hasNow);
yield return Wait(0.1f);
}
yield return Wait(0.1f);
int matsAfter = Resources.FindObjectsOfTypeAll<Material>().Length;
int sharedAfter = rend.sharedMaterial != null ? rend.sharedMaterial.GetInstanceID() : 0;
_sb.AppendLine(Pre() + "① 10회 피격 → on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " (기대 10/10 = 20) extend=" + MobHitFlash.ExtendCount + " throttled=" + MobHitFlash.ThrottledCount + " 켜진 직후 block 관측=" + onDuringSeen + "/10 · 잔류 block=" + rend.HasPropertyBlock() + " (기대 False) · 머티리얼 " + matsBefore + "→" + matsAfter + " (증가 " + (matsAfter - matsBefore) + " · 기대 0) · sharedId " + sharedId + "→" + sharedAfter + " (기대 동일) · noProp=" + MobHitFlash.SkippedNoProperty + " reflect=" + MobHitFlash.RendererFieldViaReflection);
// 같은 프레임 다중 히트 → 1회 점멸(연장)
int on0 = MobHitFlash.OnCount, ext0 = MobHitFlash.ExtendCount;
for (int i = 0; i < 5; i++) CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false));
yield return Wait(0.15f);
_sb.AppendLine(Pre() + "② 같은 프레임 5히트 → on+" + (MobHitFlash.OnCount - on0) + " extend+" + (MobHitFlash.ExtendCount - ext0) + " (기대 1 / 4) block=" + rend.HasPropertyBlock());
// Killed → 디졸브 엣지 MPB(Shader.Find 폴백) → Spawned → 정리
CombatEvents.RaiseKilled(mob);
_sb.AppendLine(Pre() + "③ Killed(Elite) → tint=" + MobHitFlash.TintCount + " dissolve resolved/hdr/range=" + MobHitFlash.DissolveResolved + "/" + MobHitFlash.DissolveHasHdr + "/" + MobHitFlash.DissolveHasRange + " block=" + rend.HasPropertyBlock() + " (기대 True) noProp=" + MobHitFlash.SkippedNoProperty);
var mpb = new MaterialPropertyBlock(); rend.GetPropertyBlock(mpb);
_sb.AppendLine(Pre() + " MPB _EdgeAroundHDR=" + mpb.GetFloat("_EdgeAroundHDR").ToString("F2") + " _EdgeAround=" + mpb.GetFloat("_EdgeAround").ToString("F3") + " _Progress 포함=" + mpb.HasFloat("_Progress") + " (기대 False)");
CombatEvents.RaiseSpawned(mob);
_sb.AppendLine(Pre() + "④ Spawned → clear=" + MobHitFlash.ClearCount + " block=" + rend.HasPropertyBlock() + " (기대 False)");
_sb.AppendLine("# GC: 100 피격 → " + GcOf(() => CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)), 100) + " (DamageInfo 생성 포함 · 프로브 측 할당)");
Save("blink", _sb); Cleanup();
}
static string GcOf(Action act, int n)
{
for (int i = 0; i < 10; i++) act();
long m0 = GC.GetTotalMemory(false); long p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
for (int i = 0; i < n; i++) act();
long m1 = GC.GetTotalMemory(false); long p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
return "GC.GetTotalMemory delta=" + (m1 - m0) + " B · MonoUsed delta=" + (p1 - p0) + " B";
}
/// <summary>ⓕ 구독자별 100회 GC: DamageInfo 는 미리 1개 만들어 재사용(프로브 측 할당 0). 카메라/반응/점멸을 RuntimeDisabled 로 하나씩 켜서 잰다.</summary>
public IEnumerator Co_Gc()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
_sb.AppendLine("# WL811def GcCheck " + DateTime.Now.ToString("HH:mm:ss") + " (기준 각 < 2048 B)");
InstallCamera(); yield return null; yield return null;
var mat = LoadSampleMat();
var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), mat); _trash.Add(elite.gameObject);
var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), mat); _trash.Add(boss.gameObject);
var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject);
yield return null;
var dmg = MakeDamage(10, false, false);
bool c0 = WLCombatCameraSettings.RuntimeDisabled, r0 = WLReactionSettings.RuntimeDisabled, b0 = WLHitBlinkSettings.RuntimeDisabled;
Action<bool, bool, bool> set = (c, r, b) => { WLCombatCameraSettings.RuntimeDisabled = !c; WLReactionSettings.RuntimeDisabled = !r; WLHitBlinkSettings.RuntimeDisabled = !b; };
// 워밍업(상태·버퍼 1회 할당)
set(true, true, true);
CombatEvents.RaiseKilled(elite); CombatEvents.RaiseHitConfirmed(elite, dmg); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true); CombatEvents.RaiseSpawned(boss);
yield return null; CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null;
set(true, false, false); CombatCameraDirector.ResetRun();
_sb.AppendLine("camera Killed×100(쿨로 1회 발동+99 스킵): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100));
CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null;
_sb.AppendLine("camera ComboStage×100: " + GcOf(() => CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true), 100));
_sb.AppendLine("camera Spawned(boss)×100(1회 발동+99 busy): " + GcOf(() => CombatEvents.RaiseSpawned(boss), 100));
CombatCameraDirector.ReleaseAll(); yield return null;
set(false, true, false); KillChain.ClearAll(); DamageAggregator.ClearAll();
_sb.AppendLine("reaction Killed×100(연쇄 100 · 티어 4): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100));
_sb.AppendLine("reaction HitConfirmed×100(합산 1슬롯): " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100));
KillChain.ClearAll(); DamageAggregator.ClearAll(); yield return null;
set(false, false, true);
_sb.AppendLine("blink HitConfirmed×100(on 1+연장 99): " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100));
_sb.AppendLine("blink Killed×100(엣지 세팅): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100));
_sb.AppendLine("blink Spawned×100(정리): " + GcOf(() => CombatEvents.RaiseSpawned(elite), 100));
yield return null;
set(true, true, true); CombatCameraDirector.ResetRun();
_sb.AppendLine("all3 Killed×100: " + GcOf(() => CombatEvents.RaiseKilled(elite), 100));
_sb.AppendLine("all3 HitConfirmed×100: " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100));
WLCombatCameraSettings.RuntimeDisabled = c0; WLReactionSettings.RuntimeDisabled = r0; WLHitBlinkSettings.RuntimeDisabled = b0;
_sb.AppendLine("# 원복 RuntimeDisabled cam/re/blink=" + c0 + "/" + r0 + "/" + b0);
Save("gc", _sb); Cleanup();
}
/// <summary>ⓖ C8: 3 SO 런타임 off → 같은 이벤트에 카운터 0 · 카메라 필드 불변 · MPB 0 · timeScale 1 → on 복귀.</summary>
public IEnumerator Co_Toggle()
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
_sb.AppendLine("# WL811def Toggle " + DateTime.Now.ToString("HH:mm:ss"));
InstallCamera(); yield return null; yield return null;
var mat = LoadSampleMat();
var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), mat); _trash.Add(elite.gameObject);
var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), mat); _trash.Add(boss.gameObject);
var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject);
yield return null;
var rend = elite.GetComponentInChildren<MeshRenderer>();
CombatCameraDirector.ResetDiagnostics(); CombatCameraDirector.ResetRun(); KillChain.ResetDiagnostics(); KillChain.ClearAll(); DamageAggregator.ResetDiagnostics(); DamageAggregator.ClearAll(); MobHitFlash.ResetDiagnostics();
WLCombatCameraSettings.RuntimeDisabled = true; WLReactionSettings.RuntimeDisabled = true; WLHitBlinkSettings.RuntimeDisabled = true;
float minB = _cam.minDistance, yawB = _cam.rotateAround;
CombatEvents.RaiseKilled(elite); CombatEvents.RaiseSpawned(boss); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true);
for (int i = 0; i < 3; i++) CombatEvents.RaiseHitConfirmed(elite, MakeDamage(10, false, false));
yield return null; yield return null;
_sb.AppendLine(Pre() + "OFF: killCam=" + CombatCameraDirector.KillCamCount + " boss=" + CombatCameraDirector.BossCamCount + " lead=" + CombatCameraDirector.ComboLeadCount + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " chainKills=" + KillChain.KillCount + " tiers=" + KillChain.TierCount + " mergeHits=" + DamageAggregator.HitCount + " blinkOn=" + MobHitFlash.OnCount + " block=" + rend.HasPropertyBlock() + " cam(min/yaw) " + minB.ToString("F2") + "→" + _cam.minDistance.ToString("F2") + " / " + yawB.ToString("F2") + "→" + _cam.rotateAround.ToString("F2") + " timeScale=" + Time.timeScale.ToString("F2") + " (기대 전부 0 · 불변 · 1.00)");
WLCombatCameraSettings.RuntimeDisabled = false; WLReactionSettings.RuntimeDisabled = false; WLHitBlinkSettings.RuntimeDisabled = false;
CombatEvents.RaiseKilled(elite); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true);
for (int i = 0; i < 3; i++) CombatEvents.RaiseHitConfirmed(elite, MakeDamage(10, false, false));
yield return null;
_sb.AppendLine(Pre() + "ON : killCam=" + CombatCameraDirector.KillCamCount + " lead=" + CombatCameraDirector.ComboLeadCount + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " chainKills=" + KillChain.KillCount + " mergeHits=" + DamageAggregator.HitCount + " blinkOn=" + MobHitFlash.OnCount + " block=" + rend.HasPropertyBlock() + " timeScale=" + Time.timeScale.ToString("F2") + " (기대 1/1/1.30/1/3/1/True/0.35)");
yield return Wait(1.2f);
_sb.AppendLine(Pre() + "복원: timeScale=" + Time.timeScale.ToString("F2") + " block=" + rend.HasPropertyBlock() + " min=" + _cam.minDistance.ToString("F2") + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2"));
Save("toggle", _sb); Cleanup();
}
/// <summary>병합 후 실전투(메인 에디터 · Lead QA): 3 SO ON n초 / OFF n초 UnityStats 비교(WL785_Budget 스타일).</summary>
public IEnumerator Co_Budget(float sec)
{
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
_sb.AppendLine("# WL811def Budget " + DateTime.Now.ToString("HH:mm:ss") + " " + sec + " s ×2");
for (int pass = 0; pass < 2; pass++)
{
bool on = pass == 0;
WLCombatCameraSettings.RuntimeDisabled = !on; WLReactionSettings.RuntimeDisabled = !on; WLHitBlinkSettings.RuntimeDisabled = !on;
yield return Wait(0.3f);
int n = 0, mSet = 0, mDraw = 0; long aSet = 0, aDraw = 0; float worst = 0f, sum = 0f; long gc0 = GC.GetTotalMemory(false);
int kc0 = CombatCameraDirector.KillCamCount, on0 = MobHitFlash.OnCount, fl0 = DamageAggregator.FlushCount, tr0 = KillChain.TierCount;
float t = Time.unscaledTime;
while (Time.unscaledTime - t < sec)
{
yield return null;
float dt = Time.unscaledDeltaTime; worst = Mathf.Max(worst, dt); sum += dt; n++;
int sp = UnityEditor.UnityStats.setPassCalls, d = UnityEditor.UnityStats.drawCalls;
mSet = Mathf.Max(mSet, sp); mDraw = Mathf.Max(mDraw, d); aSet += sp; aDraw += d;
}
_sb.AppendLine((on ? "연출 ON " : "연출 OFF") + " avgFps=" + (n / Mathf.Max(sum, 0.001f)).ToString("F1") + " worstFrame=" + (worst * 1000f).ToString("F1") + " ms · setPass max/avg=" + mSet + "/" + (n > 0 ? aSet / n : 0) + " draw max/avg=" + mDraw + "/" + (n > 0 ? aDraw / n : 0) + " · GC+" + (GC.GetTotalMemory(false) - gc0) + " B · killCam+" + (CombatCameraDirector.KillCamCount - kc0) + " blinkOn+" + (MobHitFlash.OnCount - on0) + " flush+" + (DamageAggregator.FlushCount - fl0) + " tier+" + (KillChain.TierCount - tr0) + " frames=" + n);
}
WLCombatCameraSettings.RuntimeDisabled = false; WLReactionSettings.RuntimeDisabled = false; WLHitBlinkSettings.RuntimeDisabled = false;
Save("budget", _sb); Destroy(gameObject);
}
}
}

View File

@ -0,0 +1,209 @@
// WL-813g (#813) — 설정 에셋 2종 생성 + NewGameUI.prefab 에 텍스트 연출 노드 3개 · 슬롯 오버라이드 컴포넌트 추가.
// 실행: unity command run_script --file AgentScripts/WL813g_Apply.cs --entry WL813g_Apply.CreateSettings
// unity command run_script --file AgentScripts/WL813g_Apply.cs --entry WL813g_Apply.Resave
// unity command run_script --file AgentScripts/WL813g_Apply.cs --entry WL813g_Apply.Apply
// unity command run_script --file AgentScripts/WL813g_Apply.cs --entry WL813g_Apply.Revert
// 🔴 run_script 파일은 파일마다 독립 어셈블리 — 다른 AgentScript 상수 참조 불가(813c 교훈).
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL813g_Apply
{
public const string NewGameUIPath = "Assets/Res_Addr/MainUI/NewGameUI.prefab";
public const string SettingsDir = "Assets/WL/UI/Settings/Resources/WL";
public const string TextSettingsPath = SettingsDir + "/WLCombatTextSettings.asset";
public const string SlotPresetPath = SettingsDir + "/WLSkillSlotPreset.asset";
public const string HudPath = "IngameUIs/WL_HUD";
public const string PadPath = "IngameUIs/BattleUI";
public const string KillChainName = "WL_KillChainText";
public const string LootToastName = "WL_LootToast";
public const string StatPopupName = "WL_StatPopup";
const string TmpFontGuid = "a387e325271126742ad15569dba1ac57"; // 813c 가 실측한 SkillCard/t_cooltime 폰트
const int UILayer = 5;
static Transform FindByPath(Transform root, string path)
{
var cur = root;
foreach (var part in path.Split('/'))
{
Transform next = null;
for (int i = 0; i < cur.childCount; i++)
if (cur.GetChild(i).name == part) { next = cur.GetChild(i); break; }
if (next == null) return null;
cur = next;
}
return cur;
}
static void EnsureFolder(string dir)
{
var parts = dir.Split('/');
var cur = parts[0];
for (int i = 1; i < parts.Length; i++)
{
var next = cur + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]);
cur = next;
}
}
/// <summary>설정 에셋 2종 생성(이미 있으면 값 유지 · 덮어쓰지 않는다).</summary>
public static object CreateSettings()
{
var sb = new StringBuilder();
EnsureFolder(SettingsDir);
var text = AssetDatabase.LoadAssetAtPath<WL.UI.WLCombatTextSettings>(TextSettingsPath);
if (text == null)
{
text = ScriptableObject.CreateInstance<WL.UI.WLCombatTextSettings>();
AssetDatabase.CreateAsset(text, TextSettingsPath);
sb.AppendLine("생성: " + TextSettingsPath);
}
else sb.AppendLine("이미 있음(값 유지): " + TextSettingsPath);
var slot = AssetDatabase.LoadAssetAtPath<WL.UI.WLSkillSlotPreset>(SlotPresetPath);
if (slot == null)
{
slot = ScriptableObject.CreateInstance<WL.UI.WLSkillSlotPreset>();
AssetDatabase.CreateAsset(slot, SlotPresetPath);
sb.AppendLine("생성: " + SlotPresetPath);
}
else sb.AppendLine("이미 있음(값 유지): " + SlotPresetPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
WL.UI.WLCombatTextSettings.ClearCache();
WL.UI.WLSkillSlotPreset.ClearCache();
sb.AppendLine("── 데미지 리듬(기준서 §B 요소 2)");
sb.AppendLine(" normal=" + text.damageNormalSeconds + "s crit=" + text.damageCritSeconds +
"s critScale=" + text.damageCritScale + " maxConcurrent=" + text.damageMaxConcurrent);
sb.AppendLine("── 연쇄 문구 / 토스트 / 스탯 팝업");
sb.AppendLine(" killChain=" + text.killChainSeconds + "s punch=" + text.killChainPunchScale +
" topPx=" + text.killChainTopOffsetPx +
" · loot=" + text.lootToastSeconds + "s lines=" + text.lootToastLines +
" · stat=" + text.statPopupSeconds + "s rows=" + text.statPopupRows);
sb.AppendLine("── 슬롯 오버라이드(813n)");
sb.Append(WL.UI.WLSkillSlotOverride.ResolveDump(slot));
return sb.ToString();
}
/// <summary>무변경 재저장 — 프리팹 편집 전에 재직렬화 diff 가 생기는지 먼저 잰다(802b·813c 교훈).</summary>
public static object Resave()
{
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try { PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath); }
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.Refresh();
return NewGameUIPath + " : 무변경 재저장 (git diff 로 재직렬화 여부 확인)";
}
static Transform EnsureNode(Transform parent, string name, StringBuilder sb)
{
var t = parent.Find(name);
if (t == null)
{
var go = new GameObject(name, typeof(RectTransform));
go.layer = UILayer;
t = go.transform;
((RectTransform)t).SetParent((RectTransform)parent, false);
sb.AppendLine(parent.name + "/" + name + " : 노드 추가");
}
else sb.AppendLine(parent.name + "/" + name + " : 이미 있음");
return t;
}
public static object Apply()
{
var sb = new StringBuilder();
var text = AssetDatabase.LoadAssetAtPath<WL.UI.WLCombatTextSettings>(TextSettingsPath);
if (text == null) return "🔴 " + TextSettingsPath + " 없음 — CreateSettings 먼저";
TMPro.TMP_FontAsset font = null;
var fontPath = AssetDatabase.GUIDToAssetPath(TmpFontGuid);
if (!string.IsNullOrEmpty(fontPath)) font = AssetDatabase.LoadAssetAtPath<TMPro.TMP_FontAsset>(fontPath);
sb.AppendLine("폰트: " + (font != null ? fontPath : "「미확인」 — TMP 기본 폰트로 떨어진다"));
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
// ── ① 텍스트 연출 3종 = IngameUIs/WL_HUD 아래(802b SafeAreaFitter 레이어 · 813c 규칙)
var hud = FindByPath(root.transform, HudPath);
if (hud == null) sb.AppendLine("🔴 " + HudPath + " 없음");
else
{
var kc = EnsureNode(hud, KillChainName, sb);
var kct = kc.GetComponent<WL.UI.KillChainText>();
if (kct == null) { kct = kc.gameObject.AddComponent<WL.UI.KillChainText>(); sb.AppendLine(" KillChainText 추가"); }
if (font != null) kct.SetFont(font);
sb.AppendLine(" build made=" + kct.BuildIfNeeded() + " · " + kct.ApplyLayout());
kct.Hide();
var lt = EnsureNode(hud, LootToastName, sb);
var ltc = lt.GetComponent<WL.UI.LootToast>();
if (ltc == null) { ltc = lt.gameObject.AddComponent<WL.UI.LootToast>(); sb.AppendLine(" LootToast 추가"); }
if (font != null) ltc.SetFont(font);
sb.AppendLine(" build made=" + ltc.BuildIfNeeded() + " · " + ltc.ApplyLayout());
ltc.HideAll();
var sp = EnsureNode(hud, StatPopupName, sb);
var spc = sp.GetComponent<WL.UI.StatPopup>();
if (spc == null) { spc = sp.gameObject.AddComponent<WL.UI.StatPopup>(); sb.AppendLine(" StatPopup 추가"); }
if (font != null) spc.SetFont(font);
sb.AppendLine(" build made=" + spc.BuildIfNeeded() + " · " + spc.ApplyLayout());
spc.Hide();
}
// ── ② 슬롯 장착 로컬 오버라이드 = IngameUIs/BattleUI 인스턴스 전용 추가 컴포넌트(813c 방식)
var pad = FindByPath(root.transform, PadPath);
if (pad == null) sb.AppendLine("🔴 " + PadPath + " 없음");
else
{
var ov = pad.GetComponent<WL.UI.WLSkillSlotOverride>();
if (ov == null) { ov = pad.gameObject.AddComponent<WL.UI.WLSkillSlotOverride>(); sb.AppendLine(PadPath + " : WLSkillSlotOverride 추가"); }
else sb.AppendLine(PadPath + " : WLSkillSlotOverride 이미 있음");
var so = new SerializedObject(ov);
so.FindProperty("target").objectReferenceValue = pad.GetComponent<BattleUI>();
so.ApplyModifiedPropertiesWithoutUndo();
sb.AppendLine(" target=BattleUI 주입 · BattleUI.prefab 원본 무수정(인스턴스 전용 추가 컴포넌트)");
}
PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
public static object Revert()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
var hud = FindByPath(root.transform, HudPath);
if (hud != null)
foreach (var n in new[] { KillChainName, LootToastName, StatPopupName })
{
var t = hud.Find(n);
if (t != null) { Object.DestroyImmediate(t.gameObject); sb.AppendLine(HudPath + "/" + n + " : 제거"); }
}
var pad = FindByPath(root.transform, PadPath);
if (pad != null)
{
var ov = pad.GetComponent<WL.UI.WLSkillSlotOverride>();
if (ov != null) { Object.DestroyImmediate(ov, true); sb.AppendLine(PadPath + " : WLSkillSlotOverride 제거"); }
}
PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
}

View File

@ -0,0 +1,407 @@
// WL-813g (#813) — 에디트 모드 실측 프로브(Play 0 · 합성 이벤트).
// 실행: unity command run_script --file AgentScripts/WL813g_Probe.cs --entry WL813g_Probe.DumpSlots
// unity command run_script --file AgentScripts/WL813g_Probe.cs --entry WL813g_Probe.DumpText
// unity command run_script --file AgentScripts/WL813g_Probe.cs --entry WL813g_Probe.DumpDamage
// unity command run_script --file AgentScripts/WL813g_Probe.cs --entry WL813g_Probe.DumpPrefab
// 🔴 이 파일은 독립 어셈블리로 컴파일된다 — Assembly-CSharp 의 internal(Dispatch 등)은 못 쓴다.
// 합성 이벤트는 Assembly-CSharp 안의 헬퍼(KillChainText.RaiseFakeTier 등)를 통해 낸다(813c 교훈).
// 🔴 씬은 저장하지 않는다. 만든 임시 오브젝트는 전부 DestroyImmediate 로 회수한다.
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL813g_Probe
{
const string LogDir = "Logs";
public const string NewGameUIPath = "Assets/Res_Addr/MainUI/NewGameUI.prefab";
public const string DmgPrefabPath = "Assets/Res_Addr/Ingame/HUDDMGUI.prefab";
public const string SkillJson = "Assets/ResWork/Table/Export/_WL813n_SkillList.json";
public const string GlobalJson = "Assets/ResWork/Table/Export/GlobalValue.json";
const int UILayer = 5;
static void Write(string name, string body)
{
System.IO.Directory.CreateDirectory(LogDir);
System.IO.File.WriteAllText(System.IO.Path.Combine(LogDir, name), body, new System.Text.UTF8Encoding(true));
}
static string Desc(RectTransform rt)
{
if (rt == null) return "(rt 없음)";
return "aMin=" + rt.anchorMin + " aMax=" + rt.anchorMax + " pos=" + rt.anchoredPosition +
" size=" + rt.sizeDelta + " pivot=" + rt.pivot + " scale=" + rt.localScale;
}
// ─────────────────────────────────────────── 표 읽기(에디트 모드에서 table_* 싱글턴이 없어서 JSON 직독)
// 표는 단일 라인 JSON 배열이고 값은 전부 문자열이다(813n 실측) → 행 단위로 잘라 정규식으로 읽는다.
// (run_script 는 파일마다 독립 어셈블리라 Newtonsoft 참조를 가정하지 않는다.)
static List<string> SplitRows(string path)
{
var rows = new List<string>();
try
{
var txt = System.IO.File.ReadAllText(path, System.Text.Encoding.UTF8).TrimStart('');
foreach (System.Text.RegularExpressions.Match m in
System.Text.RegularExpressions.Regex.Matches(txt, "\\{[^{}]*\\}"))
rows.Add(m.Value);
}
catch (System.Exception e) { Debug.LogWarning("[813g] 표 읽기 실패 " + path + " : " + e.Message); }
return rows;
}
static string Field(string row, string key)
{
var m = System.Text.RegularExpressions.Regex.Match(row, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\"");
return m.Success ? m.Groups[1].Value : null;
}
static Dictionary<int, int> ReadSkillMp()
{
var map = new Dictionary<int, int>();
foreach (var row in SplitRows(SkillJson))
{
var id = Field(row, "n_SkillID"); var mp = Field(row, "n_MP");
int i, v;
if (id != null && mp != null && int.TryParse(id, out i) && int.TryParse(mp, out v)) map[i] = v;
}
return map;
}
static Dictionary<int, float> ReadSkillCool()
{
var map = new Dictionary<int, float>();
foreach (var row in SplitRows(SkillJson))
{
var id = Field(row, "n_SkillID"); var cd = Field(row, "f_CoolTime");
int i; float v;
if (id != null && cd != null && int.TryParse(id, out i) && float.TryParse(cd, out v)) map[i] = v;
}
return map;
}
static float ReadGlobal(string id, float fallback)
{
foreach (var row in SplitRows(GlobalJson))
{
if (Field(row, "s_ID") != id) continue;
float v;
if (float.TryParse(Field(row, "n_Value"), out v)) return v;
}
return fallback;
}
// ─────────────────────────────────────────── ① 슬롯 오버라이드 (발주서 §3 검증 = 4×4 덤프)
public static object DumpSlots()
{
var sb = new StringBuilder();
var preset = WL.UI.WLSkillSlotPreset.Instance;
sb.AppendLine("=== WL-813g ① 슬롯 장착 로컬 오버라이드 (에디트 모드 · Play 0) ===");
if (preset == null) { var s0 = "🔴 WLSkillSlotPreset.asset 로드 실패"; Write("WL813g_probe_slots.txt", s0); return s0; }
sb.AppendLine("[해석 표 4×4]");
sb.Append(WL.UI.WLSkillSlotOverride.ResolveDump(preset));
var mp = ReadSkillMp();
var cool = ReadSkillCool();
float mpPool = ReadGlobal("MP", -1f);
float mpRegen = ReadGlobal("MP_REGEN", -1f);
sb.AppendLine();
sb.AppendLine("[MP 게이트 · Actor.cs:733-741 과 같은 식 · SKILL_COST=0 가정 · MP 풀=" + mpPool + " (GlobalValue 실측) · 회복=" + mpRegen + "/s]");
for (int r = 0; r < preset.rows.Length; r++)
{
var row = preset.rows[r];
int n = preset.SlotCountFor(row.classId);
sb.Append("class ").Append(row.classId).Append(" :");
for (int s = 0; s < n; s++)
{
int id = preset.SkillIdFor(row.classId, s);
int need = mp.ContainsKey(id) ? mp[id] : -1;
float cd = cool.ContainsKey(id) ? cool[id] : -1f;
bool dim = mpPool > 0f && need > mpPool;
sb.Append(" [").Append(s).Append("] id=").Append(id).Append(" mp=").Append(need)
.Append(" cool=").Append(cd).Append("s dim@만탱=").Append(dim);
}
sb.AppendLine();
}
// ── 합성 SD_Equip 에 실제 ApplyTo 를 태워 before→after 를 잰다(서버 데이터 무접촉)
sb.AppendLine();
sb.AppendLine("[합성 SD_Equip 적용 · 실제 WLSkillSlotOverride.ApplyTo 경로]");
foreach (var row in preset.rows)
{
var equip = new SD_Equip();
equip.Preset = 0;
equip.Equip = new Dictionary<eItem, CodeStage.AntiCheat.ObscuredTypes.ObscuredInt>();
equip.Equip[eItem.PC] = row.classId;
equip.Skill = new Dictionary<int, List<CodeStage.AntiCheat.ObscuredTypes.ObscuredInt>>();
var lst = new List<CodeStage.AntiCheat.ObscuredTypes.ObscuredInt>();
for (int i = 0; i < 6; i++) lst.Add(0);
lst[0] = 700001; // 서버 프리셋 흉내(패시브·엉뚱한 값)
lst[1] = 0; lst[2] = 0; lst[3] = 0;
equip.Skill[0] = lst;
var restore = new List<int>();
string log = WL.UI.WLSkillSlotOverride.ApplyTo(equip, preset, row.classId, restore);
sb.AppendLine(" " + log);
var after = new StringBuilder(" Equip.Skill[0] = ");
for (int i = 0; i < lst.Count; i++) after.Append(lst[i]).Append(i < lst.Count - 1 ? ", " : "");
after.Append(" (슬롯 4·5 = 물약/회피 예약 · 건드리지 않음)");
sb.AppendLine(after.ToString());
}
sb.AppendLine();
sb.AppendLine("[C8 롤백] overrideEnabled=false 또는 에셋 삭제 → ApplyTo 미호출 = 서버 프리셋 원본");
var s1 = sb.ToString();
Write("WL813g_probe_slots.txt", s1);
return s1;
}
// ─────────────────────────────────────────── ② 텍스트 연출 3종 (합성 이벤트)
static Canvas MakeCanvas(out GameObject go)
{
go = new GameObject("WL813g_TempCanvas", typeof(RectTransform), typeof(Canvas), typeof(UnityEngine.UI.CanvasScaler));
go.layer = UILayer;
var canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = go.GetComponent<UnityEngine.UI.CanvasScaler>();
// 값을 가정하지 않는다 — 실제 NewGameUI.prefab 루트의 CanvasScaler 설정을 그대로 복제한다(802b/813c 규약).
scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.screenMatchMode = UnityEngine.UI.CanvasScaler.ScreenMatchMode.Expand;
scaler.matchWidthOrHeight = 0f;
var srcPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(NewGameUIPath);
var src = srcPrefab != null ? srcPrefab.GetComponent<UnityEngine.UI.CanvasScaler>() : null;
if (src != null)
{
scaler.uiScaleMode = src.uiScaleMode;
scaler.referenceResolution = src.referenceResolution;
scaler.screenMatchMode = src.screenMatchMode;
scaler.matchWidthOrHeight = src.matchWidthOrHeight;
}
LastScalerInfo = "CanvasScaler(원본 " + (src != null ? "복제" : "「미확인」 기본값") + ") mode=" + scaler.uiScaleMode +
" ref=" + scaler.referenceResolution + " match=" + scaler.screenMatchMode + "/" + scaler.matchWidthOrHeight;
var rt = (RectTransform)go.transform;
rt.sizeDelta = new Vector2(1080f, 1920f); // 에디트 모드에서 캔버스가 스스로 크기를 못 잡으므로 기준 화면 주입
return canvas;
}
static string LastScalerInfo = "";
static RectTransform Child(RectTransform parent, string name)
{
var go = new GameObject(name, typeof(RectTransform));
go.layer = UILayer;
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
return rt;
}
public static object DumpText()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813g ② 텍스트 연출 3종 · 합성 이벤트 (에디트 모드 · Play 0) ===");
var st = WL.UI.WLCombatTextSettings.Instance;
if (st == null) { var s0 = "🔴 WLCombatTextSettings.asset 로드 실패"; Write("WL813g_probe_text.txt", s0); return s0; }
sb.AppendLine("설정: enabled=" + WL.UI.WLCombatTextSettings.Enabled +
" killChain=" + st.killChainSeconds + "s/punch " + st.killChainPunchScale +
" loot=" + st.lootToastSeconds + "s/" + st.lootToastLines + "줄" +
" stat=" + st.statPopupSeconds + "s/" + st.statPopupRows + "줄");
GameObject canvasGo;
var canvas = MakeCanvas(out canvasGo);
try
{
var croot = (RectTransform)canvas.transform;
sb.AppendLine(LastScalerInfo + " → unitsPerPx=" + st.UnitsPerPx(canvas).ToString("F4") +
" (802b 실측 1.7778 과 대조)");
// ── (A) 연쇄 처치 문구
var kcRt = Child(croot, "WL_KillChainText");
var kc = kcRt.gameObject.AddComponent<WL.UI.KillChainText>();
sb.AppendLine();
sb.AppendLine("[A] KillChainText");
sb.AppendLine(" init: " + kc.Initialize());
sb.AppendLine(" 구독자 수(KillChain.TierReached) = " + WL.Combat.Reaction.KillChain.TierReached.Count);
var tiers = WL.Combat.Reaction.WLReactionSettings.Instance != null
? WL.Combat.Reaction.WLReactionSettings.Instance.tiers : null;
if (tiers == null) sb.AppendLine(" 🔴 WLReactionSettings.tiers 없음(811e 에셋 미로드)");
else
for (int i = 0; i < tiers.Length; i++)
{
sb.AppendLine(" ── 합성 " + WL.UI.KillChainText.RaiseFakeTier(i, tiers[i].name, tiers[i].kills));
sb.Append(" ").Append(kc.Dump());
}
kc.Subscribe(false);
sb.AppendLine(" 구독 해제 후 구독자 = " + WL.Combat.Reaction.KillChain.TierReached.Count);
// ── (B) 전리품 토스트
var ltRt = Child(croot, "WL_LootToast");
var lt = ltRt.gameObject.AddComponent<WL.UI.LootToast>();
sb.AppendLine();
sb.AppendLine("[B] LootToast");
sb.AppendLine(" init: " + lt.Initialize());
sb.AppendLine(" 구독자 수(CombatEvents.Killed) = " + WL.Combat.Core.CombatEvents.Killed.Count);
sb.AppendLine(" ── " + WL.UI.LootToast.RaiseFakeKilled() + " → killedSeen=" + lt.KilledSeen);
// 등급 1~6 을 순서대로 밀어 넣어 동시 줄 상한을 잰다(표 이름이 없으면 ID 를 이름으로 쓴다)
int[] fakeItems = { 1001, 1002, 1003, 1004, 1005 };
for (int i = 0; i < fakeItems.Length; i++)
sb.AppendLine(" push[" + i + "] " + lt.Push(fakeItems[i], 1) + " → visible=" + lt.VisibleLines);
sb.AppendLine(" 중복 병합 " + lt.Push(fakeItems[fakeItems.Length - 1], 2) + " → visible=" + lt.VisibleLines);
sb.Append(lt.Dump());
lt.Subscribe(false);
sb.AppendLine(" 구독 해제 후 구독자 = " + WL.Combat.Core.CombatEvents.Killed.Count);
// ── (C) 레벨업 스탯 팝업
var spRt = Child(croot, "WL_StatPopup");
var sp = spRt.gameObject.AddComponent<WL.UI.StatPopup>();
sb.AppendLine();
sb.AppendLine("[C] StatPopup");
sb.AppendLine(" init: " + sp.Initialize());
sb.AppendLine(" 구독자 수(CombatEvents.LevelUp) = " + WL.Combat.Core.CombatEvents.LevelUp.Count);
sb.AppendLine(" ── " + WL.UI.StatPopup.RaiseFakeLevelUp(7) + " (PC 없음 → 스냅샷 대체 경로도 값 0 · 표시는 제목만)");
sb.Append(sp.Dump());
sb.AppendLine(" ── 813k 페이로드 모양으로 직접 표시:");
sb.AppendLine(" " + sp.ShowFake(8, new[] { "STR", "DEX", "INT" }, new double[] { 120, 80, 45 }, new double[] { 128, 84, 48 }));
sb.Append(sp.Dump());
sp.Subscribe(false);
sb.AppendLine(" 구독 해제 후 구독자 = " + WL.Combat.Core.CombatEvents.LevelUp.Count);
}
finally
{
if (canvasGo != null) Object.DestroyImmediate(canvasGo);
}
var s1 = sb.ToString();
Write("WL813g_probe_text.txt", s1);
return s1;
}
// ─────────────────────────────────────────── ③ 데미지 숫자 리듬 (실제 HUDDMGUI 프리팹)
public static object DumpDamage()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813g ③ 데미지 숫자 리듬 · 실제 HUDDMGUI 프리팹 (에디트 모드 · Play 0) ===");
sb.AppendLine("설정: " + HUDDMGUI.SettingsDump());
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(DmgPrefabPath);
if (prefab == null) { var s0 = "🔴 " + DmgPrefabPath + " 없음"; Write("WL813g_probe_damage.txt", s0); return s0; }
GameObject canvasGo;
var canvas = MakeCanvas(out canvasGo);
var made = new List<GameObject>();
try
{
HUDDMGUI.ClearActiveList();
var st = WL.UI.WLCombatTextSettings.Instance;
int cap = st != null ? st.damageMaxConcurrent : 0;
var croot = (RectTransform)canvas.transform;
System.Func<HUDDMGUI> spawn = () =>
{
var go = (GameObject)Object.Instantiate(prefab, croot);
go.name = "HUDDMGUI_" + made.Count;
made.Add(go);
return go.GetComponent<HUDDMGUI>();
};
// (1) 일반 vs 크리 — 표시 시간 · 배율(기준서 §B 요소 2 = 0.6 / 1.0 / 1.6배)
sb.AppendLine();
sb.AppendLine("[1] 일반 / 크리 규격");
var a = spawn(); a.SetCore(null, "<color=#FFFFFF>", 1234, eStat.None);
sb.AppendLine(" 일반 : " + a.DumpState());
var b = spawn(); b.SetCore(null, "<color=#FFFFFF>", 1234, eStat.CRI);
sb.AppendLine(" 크리 : " + b.DumpState());
float sa = a.dmgs[0].transform.localScale.x, sbb = b.dmgs[1].transform.localScale.x;
sb.AppendLine(" 크기 비 = " + (sa > 0f ? (sbb / sa).ToString("F3") : "?") + " (목표 " + (st != null ? st.damageCritScale : 1f) + ")");
// (2) 합산 윈도우 — 같은 대상 · 같은 색이면 새 숫자 대신 합산(811e 0.25 s)
sb.AppendLine();
sb.AppendLine("[2] 합산 윈도우 (같은 대상 · 같은 색)");
var targetGo = new GameObject("WL813g_FakeVictim"); made.Add(targetGo);
var t = targetGo.transform;
HUDDMGUI.ClearActiveList();
var m0 = spawn(); m0.SetCore(t, "<color=#FFFFFF>", 100, eStat.None);
sb.AppendLine(" 1타 : " + m0.DumpState() + " | active=" + HUDDMGUI.ActiveCount);
var m1 = spawn(); m1.SetCore(t, "<color=#FFFFFF>", 250, eStat.None);
sb.AppendLine(" 2타 : " + m0.DumpState() + " | active=" + HUDDMGUI.ActiveCount + " merged=" + HUDDMGUI.MergedCount);
var m2 = spawn(); m2.SetCore(t, "<color=#FFFFFF>", 900, eStat.CRI);
sb.AppendLine(" 3타(크리) : " + m0.DumpState() + " | active=" + HUDDMGUI.ActiveCount + " merged=" + HUDDMGUI.MergedCount);
sb.AppendLine(" → 합산 창 안에서는 인스턴스가 늘지 않고 합계·타수만 오른다(크리 섞이면 크리 규격으로 승격)");
// (3) 동시 상한 — 상한 초과 시 오래된 일반부터 회수
sb.AppendLine();
sb.AppendLine("[3] 동시 표시 상한 (설정 " + cap + ")");
HUDDMGUI.ClearActiveList();
int n = Mathf.Max(1, cap) + 5;
for (int i = 0; i < n; i++)
{
var g = new GameObject("victim" + i); made.Add(g);
var h = spawn();
h.SetCore(g.transform, "<color=#FFFFFF>", 10 + i, (i % 4 == 0) ? eStat.CRI : eStat.None);
}
sb.AppendLine(" " + n + "개 요청 → active=" + HUDDMGUI.ActiveCount + " (상한 " + cap + ") evicted=" + HUDDMGUI.EvictedCount +
" · 상한 준수=" + (cap <= 0 || HUDDMGUI.ActiveCount <= cap));
// (4) C8 롤백 — 설정 off 면 원본 상수(0.9 s · 배율 1)
sb.AppendLine();
sb.AppendLine("[4] C8 롤백 (RuntimeDisabled=true = 에셋 없음과 같은 경로)");
HUDDMGUI.ClearActiveList();
WL.UI.WLCombatTextSettings.RuntimeDisabled = true;
var c = spawn(); c.SetCore(null, "<color=#FFFFFF>", 777, eStat.CRI);
sb.AppendLine(" 크리(롤백) : " + c.DumpState());
sb.AppendLine(" 설정 덤프 : " + HUDDMGUI.SettingsDump());
WL.UI.WLCombatTextSettings.RuntimeDisabled = false;
}
finally
{
for (int i = made.Count - 1; i >= 0; i--) if (made[i] != null) Object.DestroyImmediate(made[i]);
if (canvasGo != null) Object.DestroyImmediate(canvasGo);
HUDDMGUI.ClearActiveList();
}
var s1 = sb.ToString();
Write("WL813g_probe_damage.txt", s1);
return s1;
}
// ─────────────────────────────────────────── ④ 프리팹 노드 (통째 Read 금지 · 노드만)
public static object DumpPrefab()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813g ④ NewGameUI.prefab 추가 노드 실측 ===");
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
var ingame = root.transform.Find("IngameUIs");
if (ingame == null) { sb.AppendLine("🔴 IngameUIs 없음"); }
else
{
var hud = ingame.Find("WL_HUD");
sb.AppendLine("IngameUIs/WL_HUD : " + (hud != null ? "있음 children=" + hud.childCount : "🔴 없음"));
if (hud != null)
{
sb.AppendLine(" SafeAreaFitter=" + (hud.GetComponent<WL.UI.SafeAreaFitter>() != null));
foreach (Transform c in hud)
sb.AppendLine(" child " + c.name + " " + Desc(c as RectTransform) +
" comps=" + string.Join(",", System.Array.ConvertAll(c.GetComponents<Component>(), x => x.GetType().Name)));
}
var pad = ingame.Find("BattleUI");
sb.AppendLine("IngameUIs/BattleUI : " + (pad != null ? "있음" : "🔴 없음"));
if (pad != null)
sb.AppendLine(" comps=" + string.Join(",", System.Array.ConvertAll(pad.GetComponents<Component>(), x => x.GetType().Name)));
}
}
finally { PrefabUtility.UnloadPrefabContents(root); }
var s1 = sb.ToString();
Write("WL813g_probe_prefab.txt", s1);
return s1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,10 @@
// WL-813g (#813) — 데미지 숫자 리듬화. 원본(힘민지) HUDDMGUI 에 표시 규칙만 얹는다.
// 기준서 v1 §B 요소 2: 일반 0.6 s · 크리 1.0 s · 크리 1.6배 · 합산 윈도우 0.25 s · 동시 상한(숫자죽 방지).
// 값은 전부 Assets/WL/UI/Settings/Resources/WL/WLCombatTextSettings.asset (C45 · 코드 상수 0).
// 🔴 C8 롤백: 에셋이 없거나 textEnabled/damageRhythmEnabled = false 면 **아래 원본 상수**(0.9 s · 배율 1 · 상한 없음)
// 로 그대로 돈다 — 호출부(Actor.cs:616/789/811/841)와 프리팹은 무수정이다.
using System.Collections.Generic;
using TMPro;
using UnityEngine;
@ -5,12 +12,49 @@ public class HUDDMGUI : HUDBase
{
public TextMeshProUGUI[] dmgs;
// ── 원본 상수(롤백 기준값) — 설정 에셋이 없을 때 이 값으로 돈다
private float moveSpeed = 1.0f;
private float alphaSpeed = 0.5f;
private float destroyTime = .9f;
bool usedmg2;
int m_Index;
// ── WL-813g 상태(설정이 꺼져 있으면 전부 미사용)
static readonly List<HUDDMGUI> s_active = new List<HUDDMGUI>();
/// <summary>프로브가 읽는다 — 지금 화면에 떠 있는 숫자 수.</summary>
public static int ActiveCount { get { return s_active.Count; } }
/// <summary>프로브가 읽는다 — 상한 초과로 회수한 횟수 · 합산으로 흡수한 횟수.</summary>
public static int EvictedCount, MergedCount;
public static void ResetDiagnostics() { EvictedCount = MergedCount = 0; }
bool m_wl; // 이번 표시에 813g 규칙이 적용됐는가
bool m_crit; // 크리티컬로 표시 중인가
float m_life, m_lifeMax; // 남은/전체 표시 시간(unscaled 아님 — 원본과 같은 Time.deltaTime 계열)
float m_rise, m_fadeStart, m_scale;
string m_color; // 합산 판정 키(같은 색끼리만 합친다 = 피해/회복/마나 섞임 방지)
double m_total; int m_hits;
/// <summary>이 인스턴스가 합산 중인 대상(합산 창이 살아 있는 동안만 유효).</summary>
public Transform MergeTarget { get { return m_MergeTarget; } }
Transform m_MergeTarget;
float m_mergeUntil;
static WL.UI.WLCombatTextSettings St
{
get { return WL.UI.WLCombatTextSettings.Enabled ? WL.UI.WLCombatTextSettings.Instance : null; }
}
/// <summary>합산 윈도우 길이 — 데이터 SOT 는 811e(WLReactionSettings). 없으면 합산하지 않는다.</summary>
static float MergeWindow
{
get
{
var r = WL.Combat.Reaction.WLReactionSettings.Instance;
if (r == null || !r.enabled || !r.damageMergeEnabled) return 0f;
return r.damageMergeWindowSeconds;
}
}
public void Set(Transform _target, string TextColor, double Damage, eStat Critical)
{
if (NewGameUI.Ins.m_BangchiUI.isActiveAndEnabled || !OptionInfo.Ins.go_check[0].activeSelf)
@ -18,32 +62,198 @@ public class HUDDMGUI : HUDBase
Off(true);
return;
}
SetCore(_target, TextColor, Damage, Critical);
}
/// <summary>
/// 표시 본체 — 원본 `Set` 의 UI 게이트(방치UI · 옵션) 뒤 부분을 그대로 뺀 것.
/// 게이트가 `NewGameUI`·`OptionInfo` 싱글턴을 요구해 Play 없이는 못 부르므로,
/// 에디트 모드 프로브(WL-813g ⓑ)가 **런타임과 같은 코드**를 재도록 분리했다(813c 교훈).
/// </summary>
public void SetCore(Transform _target, string TextColor, double Damage, eStat Critical)
{
var st = St;
bool wl = st != null && st.damageRhythmEnabled;
// ── 합산 윈도우: 같은 대상 · 같은 색이면 새 숫자를 띄우지 않고 살아 있는 숫자에 더한다(기준서 §B 0.25 s)
if (wl && st.damageUseMergeWindow && _target != null)
{
float win = MergeWindow;
if (win > 0f)
{
for (int i = 0; i < s_active.Count; i++)
{
var h = s_active[i];
if (h == null || h == this) continue;
if (h.m_MergeTarget != _target || h.m_color != TextColor) continue;
if (Time.time > h.m_mergeUntil) continue;
h.Accumulate(Damage, Critical, st);
MergedCount++;
Off(true); // 이 인스턴스는 쓰지 않고 풀로 돌려보낸다
return;
}
}
}
gameObject.SetActive(true);
Set_Target(_target);
Set_Pos();
Vector3 randomPosition = new Vector3(
Random.Range(-25f, 25f), // x 축 랜덤 범위
Random.Range(-25f, 25f) // y 축 랜덤 범위
);
dmgs[0].transform.localPosition = dmgs[1].transform.localPosition = randomPosition;
//dmgs[0].transform.localPosition = dmgs[1].transform.localPosition = Vector3.zero;
dmgs[0].alpha = dmgs[1].alpha = 1f;
dmgs[0].text = dmgs[1].text = DSUtil.Format("{0}{1}", TextColor, NumberFormatter.FormatNumber(Damage));
if (m_Target != null && m_Camera != null && m_CanvasRect != null) Set_Pos(); // 대상·카메라·캔버스가 다 있을 때만(원본은 항상 있다)
usedmg2 = Critical == eStat.CRI || Critical == eStat.M_CRI;
m_Index = usedmg2 ? 1 : 0;
dmgs[0].enabled = !usedmg2;
dmgs[1].enabled = usedmg2;
m_Index = usedmg2 ? 1 : 0;
dmgs[0].alpha = dmgs[1].alpha = 1f;
OffTime = destroyTime;
m_wl = wl;
m_crit = usedmg2;
m_color = TextColor;
m_total = Damage;
m_hits = 1;
m_MergeTarget = _target;
float scatter = wl ? st.damageScatterPx * (usedmg2 ? st.damageCritScatterMultiplier : 1f) : 25f;
Vector3 randomPosition = new Vector3(
Random.Range(-scatter, scatter), // x 축 랜덤 범위
Random.Range(-scatter, scatter)); // y 축 랜덤 범위
dmgs[0].transform.localPosition = dmgs[1].transform.localPosition = randomPosition;
if (wl)
{
m_lifeMax = m_life = usedmg2 ? st.damageCritSeconds : st.damageNormalSeconds;
m_scale = usedmg2 ? st.damageCritScale : st.damageNormalScale;
m_rise = st.damageRiseSpeed * (usedmg2 ? st.damageCritRiseMultiplier : 1f);
m_fadeStart = Mathf.Clamp01(st.damageFadeStartRatio);
m_mergeUntil = Time.time + Mathf.Max(0f, MergeWindow);
dmgs[m_Index].transform.localScale = Vector3.one * m_scale;
OffTime = m_lifeMax;
Register(st);
}
else
{
m_lifeMax = m_life = destroyTime;
m_scale = 1f; m_rise = moveSpeed; m_fadeStart = 0f;
m_mergeUntil = 0f;
dmgs[0].transform.localScale = dmgs[1].transform.localScale = Vector3.one;
OffTime = destroyTime;
Register(null);
}
Redraw(TextColor, st);
isUpdate = false;
}
/// <summary>합산 윈도우 안의 추가 타격 — 숫자를 더하고, 크리가 섞이면 크리 규격으로 승격한다.</summary>
void Accumulate(double Damage, eStat Critical, WL.UI.WLCombatTextSettings st)
{
m_total += Damage;
m_hits++;
bool crit = Critical == eStat.CRI || Critical == eStat.M_CRI;
if (crit && !m_crit)
{
m_crit = usedmg2 = true;
var pos = dmgs[m_Index].transform.localPosition;
m_Index = 1;
dmgs[0].enabled = false; dmgs[1].enabled = true;
dmgs[1].transform.localPosition = pos;
m_scale = st.damageCritScale;
m_lifeMax = st.damageCritSeconds;
m_rise = st.damageRiseSpeed * st.damageCritRiseMultiplier;
}
m_life = m_lifeMax; // 창이 이어지는 동안 표시 시간을 되감는다
OffTime = m_lifeMax;
dmgs[m_Index].alpha = 1f;
dmgs[m_Index].transform.localScale = Vector3.one * m_scale;
m_mergeUntil = Time.time + Mathf.Max(0f, MergeWindow);
Redraw(m_color, st);
}
void Redraw(string TextColor, WL.UI.WLCombatTextSettings st)
{
string num = NumberFormatter.FormatNumber(m_total);
if (m_wl && st != null && m_hits >= Mathf.Max(2, st.damageMergeHitsMin) && !string.IsNullOrEmpty(st.damageMergeHitsSuffix))
num = num + DSUtil.Format(st.damageMergeHitsSuffix, m_hits);
dmgs[0].text = dmgs[1].text = DSUtil.Format("{0}{1}", TextColor, num);
}
// ── 동시 표시 상한 (기준서 §B 요소 2 「초당 3~5개 겹침」 방지)
void Register(WL.UI.WLCombatTextSettings st)
{
if (!s_active.Contains(this)) s_active.Add(this);
if (st == null || st.damageMaxConcurrent <= 0) return;
while (s_active.Count > st.damageMaxConcurrent)
{
int victim = -1;
if (st.damageEvictNormalFirst)
for (int i = 0; i < s_active.Count; i++)
if (s_active[i] != null && s_active[i] != this && !s_active[i].m_crit) { victim = i; break; }
if (victim < 0)
for (int i = 0; i < s_active.Count; i++)
if (s_active[i] != null && s_active[i] != this) { victim = i; break; }
if (victim < 0) break;
var h = s_active[victim];
EvictedCount++;
h.Off(true); // OnDisable 이 목록에서 뺀다
if (s_active.Count > victim && s_active[victim] == h) s_active.RemoveAt(victim);
}
}
void OnDisable()
{
s_active.Remove(this);
m_MergeTarget = null;
m_mergeUntil = 0f;
if (dmgs != null)
{
if (dmgs.Length > 0 && dmgs[0] != null) dmgs[0].transform.localScale = Vector3.one;
if (dmgs.Length > 1 && dmgs[1] != null) dmgs[1].transform.localScale = Vector3.one;
}
}
/// <summary>프로브(에디트 모드)용 상태 덤프 — 실측 값만.</summary>
public string DumpState()
{
var t = dmgs[m_Index].transform;
return "wl=" + m_wl + " crit=" + m_crit + " hits=" + m_hits + " total=" + m_total.ToString("N0") +
" life=" + m_life.ToString("F2") + "/" + m_lifeMax.ToString("F2") +
" scale=" + t.localScale.x.ToString("F3") + " rise=" + m_rise.ToString("F2") +
" fadeStart=" + m_fadeStart.ToString("F2") + " alpha=" + dmgs[m_Index].alpha.ToString("F2") +
" idx=" + m_Index + " enabled0/1=" + dmgs[0].enabled + "/" + dmgs[1].enabled +
" localPos=" + t.localPosition + " text=\"" + dmgs[m_Index].text + "\"";
}
/// <summary>프로브용 — 지금 설정이 만들어 내는 리듬 값(코드 상수 0 확인용).</summary>
public static string SettingsDump()
{
var st = St;
if (st == null) return "WLCombatTextSettings 없음/off — 원본 상수(0.9 s · 배율 1 · 상한 없음)로 동작";
return "rhythm=" + st.damageRhythmEnabled + " normal=" + st.damageNormalSeconds + "s crit=" + st.damageCritSeconds +
"s critScale=" + st.damageCritScale + " normalScale=" + st.damageNormalScale +
" maxConcurrent=" + st.damageMaxConcurrent + " evictNormalFirst=" + st.damageEvictNormalFirst +
" mergeWindow=" + MergeWindow.ToString("F2") + "s(811e) useMerge=" + st.damageUseMergeWindow +
" scatterPx=" + st.damageScatterPx + " critScatterMul=" + st.damageCritScatterMultiplier;
}
/// <summary>프로브용 — 활성 목록을 비운다(에디트 모드 반복 실행 대비).</summary>
public static void ClearActiveList() { s_active.Clear(); EvictedCount = MergedCount = 0; }
private void LateUpdate()
{
dmgs[m_Index].transform.Translate(new Vector3(0, moveSpeed * Time.deltaTime, 0)); // 텍스트 위치
dmgs[m_Index].alpha = Mathf.Lerp(dmgs[m_Index].alpha, 0, Time.deltaTime * alphaSpeed); // 텍스트 알파값
var t = dmgs[m_Index].transform;
if (!m_wl)
{ // ── 원본 경로 그대로
t.Translate(new Vector3(0, moveSpeed * Time.deltaTime, 0)); // 텍스트 위치
dmgs[m_Index].alpha = Mathf.Lerp(dmgs[m_Index].alpha, 0, Time.deltaTime * alphaSpeed); // 텍스트 알파값
return;
}
m_life -= Time.deltaTime;
t.Translate(new Vector3(0, m_rise * Time.deltaTime, 0));
float passed = m_lifeMax > 0f ? 1f - Mathf.Clamp01(m_life / m_lifeMax) : 1f;
if (passed >= m_fadeStart)
{
float k = m_fadeStart >= 1f ? 1f : (passed - m_fadeStart) / (1f - m_fadeStart);
dmgs[m_Index].alpha = 1f - Mathf.Clamp01(k);
}
}
}
}

View File

@ -57,6 +57,12 @@ public class CameraMoveLead
/// <summary>에디터/진단에서 에셋을 갈아끼울 때.</summary>
public static void ReloadSettings() { s_Loaded = false; s_Settings = null; }
/// <summary>
/// WL #811d — 런타임 리드 배율(전진·후진·측면 리드 전부에 곱한다 · 기본 1). 3타 ComboStage(isLast) 에
/// CombatCameraDirector 가 잠시 1.3 으로 올렸다가 되돌린다(값은 WLCombatCameraSettings). 1 이면 기존 동작과 동일.
/// </summary>
public static float LeadMultiplier = 1f;
// ── 상태
Transform _bound; // 현재 추적 중인 타겟(바뀌면 리셋)
Vector3 _prevPos;
@ -174,11 +180,11 @@ public class CameraMoveLead
}
// ── 리드 거리비 스케일 (실내·보스에서 카메라가 가까우면 리드도 함께 줄어든다)
float leadScale = 1f;
float leadScale = LeadMultiplier; // #811d 3타 리드 배율(기본 1)
if (st.scaleLeadByDistance)
{
float refDist = st.leadReferenceDistance > 0.0001f ? st.leadReferenceDistance : _bindDistance;
if (refDist > 0.0001f) leadScale = distanceAway / refDist;
if (refDist > 0.0001f) leadScale *= distanceAway / refDist;
}
// ── 전진 리드(진행 방향) + 후진 리드(camFwd 축 · backwardLeadDistance 음수면 카메라 쪽으로 당김)

View File

@ -0,0 +1,361 @@
// ─────────────────────────────────────────────────────────────────────────────
// CombatCameraDirector.cs — 전투 연출 카메라: 킬캠(줌인+슬로모) · 보스 등장 캠(오빗) · 3타 카메라 리드
//
// PD 지시 #813 · 발주서 WL-811def §1-1 · 설계안 WL-811a §C 811d 행 · 기준서 v1 §B 요소 6·7 · §F-4 (2026-09-09)
//
// ■ 무엇을 하나 (전부 코어 이벤트 **구독** · 원본 훅 0 · RealCamera 무수정)
// Killed(Elite/Boss) → TimeScaleArbiter.Request(Cinematic · cinematicDefaultScale · killCamSlowMoSeconds)
// + RealCamera 궤도 거리(minDistance/maxDistance[/DistanceUp]) × killCamZoomScale 램프 → 유지 → 복원
// 쿨 = 코어 killCamCooldownSeconds · 런당 killCamMaxPerRun · 하단 40% 가드(처치 위치 뷰포트 y)
// Spawned(isBoss) → arr_cCamData[1] 보스 프리셋 덮어쓰기(옵션) · Set_Target(보스) · Add_Rot 로 sin 오빗 · 거리 배율 · 끝나면 전부 복원
// ComboStage(isLast) → CameraMoveLead.LeadMultiplier = comboLeadMultiplier (comboLeadSeconds 뒤 1 로)
//
// ■ 왜 RealCamera 공개 필드를 매 프레임 쓰는가 (사후 가산 기각 근거 = CameraMoveLead.cs / WLHitFeelCameraShake.cs 주석)
// RealCamera.Update_Cam 은 자기 출력을 다시 입력으로 읽는 저역통과 필터라 transform 에 오프셋을 더하면 13배 반대 방향으로 튄다.
// 대신 그 필터의 **입력**(궤도 거리 파라미터)을 바꾼다: DistanceAway 는 매 LateUpdate 마다 [minDistance, maxDistance] 로 클램프되므로
// 두 값을 같이 줄이면 그 프레임부터 궤도가 줄고 Lerp(smooth 4)가 자연스러운 줌을 만든다. 드로우 +0 · 프리팹 무수정.
//
// ■ 안전장치 (TimeScaleArbiter 규약 승계)
// · 복원은 "우리가 마지막에 쓴 값이 그대로 남아 있을 때만" — Init_Cam/Set_InsideBuilding 등 외부가 덮어썼으면 포기(덮어쓰지 않음)
// · 인트로(CamStatus 0 = IsEnter) 중에는 줌·오빗을 걸지 않는다 · RealCamera 가 없으면(타이틀 등) 슬로모만
// · 보스 캠 중 킬캠 줌은 스킵(슬로모만) · 킬캠 중 보스 스폰은 킬캠을 즉시 복원하고 보스 캠 시작
// · 씬 전환 시 상태·런 카운터 초기화 · 러너 파괴/앱 종료 시 전부 복원
// · 시간은 전부 unscaled(슬로모 중에도 램프가 늘어지지 않는다) · 핸들러 GC 0(verboseLog 때만 문자열)
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.SceneManagement;
using WL.Combat.Core;
namespace WL.CombatCamera
{
public static class CombatCameraDirector
{
enum Phase { None = 0, ZoomIn = 1, Hold = 2, ZoomOut = 3 }
// ── 킬캠 상태
static Phase s_phase;
static float s_phaseStart, s_killStart;
static float s_scale = 1f;
static float s_baseMin, s_baseMax, s_baseUp; // 발동 시점 스냅샷
static float s_wMin, s_wMax, s_wUp; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지)
static bool s_scaleUp;
static float s_lastKillCam = -999f;
static int s_runCount;
static RealCamera s_cam;
// ── 보스 캠 상태
static bool s_bossActive;
static float s_bossStart;
static Transform s_bossTf, s_prevTarget;
static bool s_bossRetargeted;
static float s_bossYawApplied;
static float s_bossBaseMin, s_bossBaseMax, s_bossWMin, s_bossWMax;
// ── 3타 리드 상태
static float s_leadUntil = -1f;
static CombatCameraRunner s_runner;
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int KillCamCount, KillCamSkippedRole, KillCamSkippedCooldown, KillCamSkippedRunCap, KillCamSkippedGuard, KillCamSkippedNoCam, KillCamSkippedBusy, KillCamAbandoned;
public static int BossCamCount, BossCamSkipped, BossPresetApplied, ComboLeadCount;
public static ArbiterResult LastSlowMoResult;
public static float LastVictimViewportY = -1f;
public static float LastKillCamMeasured; // 줌 시작 → 완전 복원까지(초 · unscaled)
public static float LastBossCamMeasured;
public static string LastInfo = "";
public static bool KillCamActive { get { return s_phase != Phase.None; } }
public static bool BossCamActive { get { return s_bossActive; } }
public static float CurrentZoomScale { get { return s_scale; } }
public static int RunCount { get { return s_runCount; } }
public static float LeadUntil { get { return s_leadUntil; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
CombatEvents.ComboStage.Add(OnComboStage);
SceneManager.activeSceneChanged += OnSceneChanged;
Subscribed = true;
}
static WLCombatCameraSettings St { get { return WLCombatCameraSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg)
{
var st = St;
if (st != null && st.verboseLog) Debug.Log("[CombatCameraDirector] " + msg);
}
static CombatCameraRunner Runner
{
get
{
if (s_runner == null)
{
var go = new GameObject("__WLCombatCamera");
Object.DontDestroyOnLoad(go);
s_runner = go.AddComponent<CombatCameraRunner>();
}
return s_runner;
}
}
static RealCamera ResolveCamera() { return MyValue.m_RealCamera; }
// ───────────────────────────────────────── Killed → 킬캠
static void OnKilled(in KilledEvent e)
{
if (!WLCombatCameraSettings.Enabled) return;
var st = St;
if (!st.killCamEnabled) return;
bool roleOk = e.subRole == eSubRol.Boss ? st.killCamOnBoss : e.subRole == eSubRol.Elite ? st.killCamOnElite : st.killCamOnNormal;
if (!roleOk || (st.killCamDirectHitOnly && !e.byDirectHit)) { KillCamSkippedRole++; return; }
float now = Time.unscaledTime;
var core = WLCombatCoreSettings.Instance;
float cooldown = core != null ? core.killCamCooldownSeconds : 0f;
if (now - s_lastKillCam < cooldown) { KillCamSkippedCooldown++; LastInfo = "cooldown"; if (Verbose) Log("킬캠 스킵(쿨) " + (now - s_lastKillCam).ToString("F2") + " < " + cooldown); return; }
if (st.killCamMaxPerRun > 0 && s_runCount >= st.killCamMaxPerRun) { KillCamSkippedRunCap++; LastInfo = "runcap"; if (Verbose) Log("킬캠 스킵(런 상한 " + st.killCamMaxPerRun + ")"); return; }
s_lastKillCam = now;
s_runCount++;
KillCamCount++;
// ① 슬로모 — 중재자 Cinematic 층(히트스톱은 이 동안 SkippedHigher · D-1)
float slow = core != null ? core.cinematicDefaultScale : 1f;
LastSlowMoResult = TimeScaleArbiter.Request(TimeLayer.Cinematic, slow, st.killCamSlowMoSeconds);
// ② 줌 — RealCamera 가 있고 인트로가 아니고 보스 캠 중이 아닐 때
var cam = ResolveCamera();
if (cam == null) { KillCamSkippedNoCam++; LastInfo = "slowmo-only(no cam)"; if (Verbose) Log("킬캠 줌 스킵(RealCamera 없음) 슬로모=" + LastSlowMoResult); return; }
if (cam.IsEnter() || s_bossActive) { KillCamSkippedBusy++; LastInfo = "slowmo-only(busy)"; if (Verbose) Log("킬캠 줌 스킵(인트로/보스 캠) 슬로모=" + LastSlowMoResult); return; }
if (st.killCamBottomGuardViewportY > 0f)
{
var uc = Camera.main;
if (uc != null)
{
var v = uc.WorldToViewportPoint(e.position);
LastVictimViewportY = v.y;
if (v.z > 0f && v.y < st.killCamBottomGuardViewportY)
{
KillCamSkippedGuard++; LastInfo = "slowmo-only(bottom guard)";
if (Verbose) Log("킬캠 줌 스킵(하단 가드 y=" + v.y.ToString("F2") + " < " + st.killCamBottomGuardViewportY + ") 슬로모=" + LastSlowMoResult);
return;
}
}
}
if (s_phase != Phase.None) EndZoom(true); // (쿨 안이라 사실상 없음) 안전
s_cam = cam;
s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp;
s_scaleUp = st.killCamScaleDistanceUp;
s_phase = Phase.ZoomIn; s_phaseStart = s_killStart = now;
WriteZoom(1f);
Runner.enabled = true;
LastInfo = "killcam";
if (Verbose) Log("킬캠 발동 victim=" + (e.victim != null ? e.victim.name : "?") + " sub=" + e.subRole + " 슬로모=" + LastSlowMoResult + " base=" + s_baseMin.ToString("F2") + "/" + s_baseMax.ToString("F2") + "/" + s_baseUp.ToString("F2"));
}
static void WriteZoom(float k)
{
var cam = s_cam;
s_scale = k;
cam.minDistance = s_wMin = s_baseMin * k;
cam.maxDistance = s_wMax = s_baseMax * k;
if (s_scaleUp) cam.DistanceUp = s_wUp = s_baseUp * k;
}
static bool ZoomStillOurs()
{
var cam = s_cam;
if (cam == null) return false;
if (!Mathf.Approximately(cam.minDistance, s_wMin) || !Mathf.Approximately(cam.maxDistance, s_wMax)) return false;
if (s_scaleUp && !Mathf.Approximately(cam.DistanceUp, s_wUp)) return false;
return true;
}
static void EndZoom(bool restore)
{
if (s_phase == Phase.None) return;
if (restore && ZoomStillOurs())
{
var cam = s_cam;
cam.minDistance = s_baseMin; cam.maxDistance = s_baseMax;
if (s_scaleUp) cam.DistanceUp = s_baseUp;
LastKillCamMeasured = Time.unscaledTime - s_killStart;
}
else if (restore) { KillCamAbandoned++; LastKillCamMeasured = -1f; }
s_phase = Phase.None; s_scale = 1f; s_cam = null;
if (Verbose) Log("킬캠 종료 restore=" + restore + " measured=" + LastKillCamMeasured.ToString("F3"));
}
static void TickKillCam(float now)
{
if (s_phase == Phase.None) return;
var st = St;
if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndZoom(true); return; }
if (!ZoomStillOurs()) { KillCamAbandoned++; s_phase = Phase.None; s_scale = 1f; s_cam = null; LastKillCamMeasured = -1f; if (Verbose) Log("킬캠 포기(외부가 거리 필드를 바꿈)"); return; }
float t = now - s_phaseStart;
float zoom = st.killCamZoomScale;
switch (s_phase)
{
case Phase.ZoomIn:
if (t >= st.killCamZoomInSeconds) { WriteZoom(zoom); s_phase = Phase.Hold; s_phaseStart = now; }
else WriteZoom(Mathf.Lerp(1f, zoom, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomInSeconds, 0.0001f))));
break;
case Phase.Hold:
if (t >= st.killCamHoldSeconds) { s_phase = Phase.ZoomOut; s_phaseStart = now; }
break;
case Phase.ZoomOut:
if (t >= st.killCamZoomOutSeconds) EndZoom(true);
else WriteZoom(Mathf.Lerp(zoom, 1f, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomOutSeconds, 0.0001f))));
break;
}
}
// ───────────────────────────────────────── Spawned(isBoss) → 보스 캠
static void OnSpawned(in SpawnedEvent e)
{
if (!WLCombatCameraSettings.Enabled) return;
var st = St;
if (!st.bossCamEnabled || !e.isBoss || e.actor == null) return;
var cam = ResolveCamera();
if (cam == null) { BossCamSkipped++; LastInfo = "bosscam(no cam)"; return; }
if (st.bossPresetApply && cam.arr_cCamData != null && cam.arr_cCamData.Length > 1 && cam.arr_cCamData[1] != null)
{
var d = cam.arr_cCamData[1];
d.minDistance = d.maxDistance = st.bossPresetDistance;
d.cameraHeight = st.bossPresetCameraHeight;
if (st.bossPresetDistanceUp >= 0f) d.DistanceUp = st.bossPresetDistanceUp;
BossPresetApplied++;
}
if (cam.IsEnter() || s_bossActive) { BossCamSkipped++; LastInfo = "bosscam(busy)"; return; }
if (s_phase != Phase.None) EndZoom(true);
float now = Time.unscaledTime;
s_cam = cam;
s_bossActive = true; s_bossStart = now;
s_bossTf = e.actor.transform;
s_prevTarget = cam.target;
s_bossRetargeted = false;
if (st.bossCamRetarget) { cam.Set_Target(s_bossTf); s_bossRetargeted = true; }
s_bossBaseMin = cam.minDistance; s_bossBaseMax = cam.maxDistance;
cam.minDistance = s_bossWMin = s_bossBaseMin * st.bossCamDistanceScale;
cam.maxDistance = s_bossWMax = s_bossBaseMax * st.bossCamDistanceScale;
s_bossYawApplied = 0f;
BossCamCount++;
Runner.enabled = true;
LastInfo = "bosscam";
if (Verbose) Log("보스 캠 시작 boss=" + e.actor.name + " prevTarget=" + (s_prevTarget != null ? s_prevTarget.name : "null") + " retarget=" + s_bossRetargeted);
}
static void EndBossCam(bool restore)
{
if (!s_bossActive) return;
var cam = s_cam;
if (restore && cam != null)
{
if (Mathf.Abs(s_bossYawApplied) > 1e-4f) cam.Add_Rot(-s_bossYawApplied);
if (Mathf.Approximately(cam.minDistance, s_bossWMin) && Mathf.Approximately(cam.maxDistance, s_bossWMax))
{
cam.minDistance = s_bossBaseMin; cam.maxDistance = s_bossBaseMax;
}
if (s_bossRetargeted && ReferenceEquals(cam.target, s_bossTf)) cam.Set_Target(s_prevTarget); // 외부가 대상을 바꿨으면 손대지 않음
LastBossCamMeasured = Time.unscaledTime - s_bossStart;
}
s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; s_cam = null;
if (Verbose) Log("보스 캠 종료 restore=" + restore + " measured=" + LastBossCamMeasured.ToString("F3"));
}
static void TickBossCam(float now)
{
if (!s_bossActive) return;
var st = St;
if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndBossCam(true); return; }
float dur = Mathf.Max(st.bossCamSeconds, 0.0001f);
float t = (now - s_bossStart) / dur;
if (t >= 1f) { EndBossCam(true); return; }
float yaw = Mathf.Sin(t * Mathf.PI) * st.bossCamOrbitDegrees;
s_cam.Add_Rot(yaw - s_bossYawApplied);
s_bossYawApplied = yaw;
}
// ───────────────────────────────────────── ComboStage(isLast) → 3타 리드
static void OnComboStage(in ComboStageEvent e)
{
if (!WLCombatCameraSettings.Enabled) return;
var st = St;
if (!st.comboLeadEnabled || !e.isLast) return;
CameraMoveLead.LeadMultiplier = st.comboLeadMultiplier;
s_leadUntil = Time.unscaledTime + st.comboLeadSeconds;
ComboLeadCount++;
Runner.enabled = true;
if (Verbose) Log("3타 리드 ×" + st.comboLeadMultiplier + " " + st.comboLeadSeconds + " s clip=" + e.clip);
}
static void TickLead(float now)
{
if (s_leadUntil >= 0f && now >= s_leadUntil) { CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; }
}
// ───────────────────────────────────────── 공용
internal static void Tick()
{
float now = Time.unscaledTime;
TickKillCam(now);
TickBossCam(now);
TickLead(now);
}
/// <summary>활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리).</summary>
public static void ReleaseAll()
{
EndZoom(true);
EndBossCam(true);
CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f;
}
static void OnSceneChanged(Scene from, Scene to)
{
// 카메라 오브젝트가 사라졌을 수 있다 — 복원 없이 상태만 버린다(복원은 ReleaseAll 이 러너 OnDisable 에서 시도)
s_phase = Phase.None; s_scale = 1f; s_cam = null;
s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f;
CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f;
s_runCount = 0; s_lastKillCam = -999f;
}
/// <summary>프로브용: 런 카운터·쿨 초기화(연출 상태는 유지).</summary>
public static void ResetRun() { s_runCount = 0; s_lastKillCam = -999f; }
/// <summary>프로브·QA 용: 쿨만 초기화(런 카운터 유지 → 런 상한 검증).</summary>
public static void ResetCooldown() { s_lastKillCam = -999f; }
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = 0;
BossCamCount = BossCamSkipped = BossPresetApplied = ComboLeadCount = 0;
LastKillCamMeasured = LastBossCamMeasured = 0f; LastVictimViewportY = -1f; LastInfo = "";
}
}
/// <summary>연출 카메라 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성·종료 시 카메라 값을 반드시 되돌린다.</summary>
public sealed class CombatCameraRunner : MonoBehaviour
{
void Update() { CombatCameraDirector.Tick(); }
void OnDisable() { CombatCameraDirector.ReleaseAll(); }
void OnApplicationQuit() { CombatCameraDirector.ReleaseAll(); }
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,41 @@
%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: de274e4d2cb745929ac9d07882450330, type: 3}
m_Name: WLCombatCameraSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.CombatCamera.WLCombatCameraSettings
enabled: 1
verboseLog: 0
killCamEnabled: 1
killCamOnElite: 1
killCamOnBoss: 1
killCamOnNormal: 0
killCamDirectHitOnly: 0
killCamZoomScale: 0.75
killCamScaleDistanceUp: 1
killCamZoomInSeconds: 0.12
killCamHoldSeconds: 0.28
killCamZoomOutSeconds: 0.35
killCamSlowMoSeconds: 0.35
killCamBottomGuardViewportY: 0.4
killCamMaxPerRun: 6
bossCamEnabled: 1
bossCamSeconds: 1.2
bossCamRetarget: 1
bossCamOrbitDegrees: 35
bossCamDistanceScale: 1.15
bossPresetApply: 1
bossPresetDistance: 3.75
bossPresetCameraHeight: 67
bossPresetDistanceUp: -1
comboLeadEnabled: 1
comboLeadMultiplier: 1.3
comboLeadSeconds: 0.45

View File

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

View File

@ -0,0 +1,144 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLCombatCameraSettings.cs — 전투 연출 카메라(킬캠 · 보스 등장 캠 · 3타 리드) 값의 단일 출처(SOT · C45)
//
// PD 지시 #813 · 발주서 WL-811def §1-1 · 설계안 WL-811a §C 811d 행 · 기준서 v1 §B 요소 6·7 · §F-4 (2026-09-09)
//
// 에셋 경로(고정): Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset
// → Resources.Load<WLCombatCameraSettings>("WL/WLCombatCameraSettings") 로 1회 로드 후 캐시(기존 설정 SO 관례).
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 CombatCameraDirector 의 모든 구독자가 즉시 반환 = 원본 동작 100%(C8).
//
// 코어 SO(WLCombatCoreSettings)와 나눠 갖는 값 — 811b 가 811d 소비자용으로 예약해 둔 필드는 여기서 **다시 두지 않는다**(SOT 1곳):
// · 킬캠 슬로모 배율 = WLCombatCoreSettings.cinematicDefaultScale (기획안 제안값 0.35)
// · 킬캠 최소 간격(쿨) = WLCombatCoreSettings.killCamCooldownSeconds (기획안 F-0A 제안값 8 s)
//
// RealCamera(원본 · S 소유)는 수정하지 않는다 — 공개 필드 minDistance/maxDistance/DistanceUp · Add_Rot · Set_Target · arr_cCamData 만 쓴다.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.CombatCamera
{
[CreateAssetMenu(fileName = "WLCombatCameraSettings", menuName = "WL/Combat Camera Settings", order = 321)]
public sealed class WLCombatCameraSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLCombatCameraSettings";
static WLCombatCameraSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLCombatCameraSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLCombatCameraSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 건드리지 않고 이 연출만 끈다. Play 종료 시 자동 false.</summary>
public static bool RuntimeDisabled;
/// <summary>연출 카메라가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 킬캠·보스 캠·3타 리드가 전부 비활성 — RealCamera 는 100% 기존 동작. 롤백 1순위.")]
public bool enabled = true;
[Tooltip("연출 결정(발동·스킵 사유·복원)을 Console 에 남긴다(검증용 · 기본 off · GC 발생).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── 킬캠
[Header("킬캠 — Killed(엘리트/보스) → 줌인 + 슬로모(중재자 Cinematic 층)")]
[Tooltip("킬캠 사용 여부.")]
public bool killCamEnabled = true;
[Tooltip("엘리트(eSubRol.Elite) 처치에 발동.")]
public bool killCamOnElite = true;
[Tooltip("보스(eSubRol.Boss) 처치에 발동.")]
public bool killCamOnBoss = true;
[Tooltip("일반 몹 처치에도 발동(기본 off · 존 마지막 신호는 813e 뒤에 붙는다).")]
public bool killCamOnNormal = false;
[Tooltip("직접 타격 처치(byDirectHit)에만 발동. off 면 도트·연쇄 피해 처치도 발동.")]
public bool killCamDirectHitOnly = false;
[Tooltip("줌 배율 — RealCamera 궤도 거리(minDistance/maxDistance)에 곱한다. 기준서 §B 요소 7 제안값 0.75.")]
public float killCamZoomScale = 0.75f;
[Tooltip("궤도 중심 높이(DistanceUp)도 같은 배율로 줄인다 = 시야각을 유지한 균일 줌. off 면 거리만 줄어 내려다보는 각이 조금 가팔라진다.")]
public bool killCamScaleDistanceUp = true;
[Tooltip("줌인 램프 시간(초 · unscaled). RealCamera 자체 Lerp(smooth 4 · τ≈0.25 s)가 더해지므로 짧게.")]
public float killCamZoomInSeconds = 0.12f;
[Tooltip("줌 유지 시간(초 · unscaled). 줌인+유지 ≈ 기준서 0.25~0.4 s.")]
public float killCamHoldSeconds = 0.28f;
[Tooltip("줌아웃 복원 시간(초 · unscaled).")]
public float killCamZoomOutSeconds = 0.35f;
[Tooltip("슬로모 유지 시간(초 · unscaled). 배율은 코어 SO cinematicDefaultScale(0.35). 기준서 §B 요소 6 = 0.35 · 0.4 s.")]
public float killCamSlowMoSeconds = 0.35f;
[Tooltip("세로 하단 HUD 영역 보호 — 처치 위치의 뷰포트 y 가 이 값 미만(화면 아래쪽)이면 줌은 건너뛰고 슬로모만 건다(줌인이 하단으로 더 밀어 넣는 것을 막는다). 0 = 검사 안 함.")]
public float killCamBottomGuardViewportY = 0.4f;
[Tooltip("한 런(씬 로드 사이)당 킬캠 최대 횟수. 기준서 「런당 ≤6회」 제안값. 0 = 무제한.")]
public int killCamMaxPerRun = 6;
// ─────────────────────────────────────────── 보스 등장 캠
[Header("보스 등장 캠 — Spawned(isBoss) → 오빗")]
[Tooltip("보스 등장 캠 사용 여부.")]
public bool bossCamEnabled = true;
[Tooltip("오빗 길이(초 · unscaled). 기준서 §B 요소 6 = 1.2 s.")]
public float bossCamSeconds = 1.2f;
[Tooltip("오빗 동안 카메라 추적 대상을 보스로 바꾼다(Set_Target) — 끝나면 원래 대상으로 복원(외부가 바꿨으면 복원하지 않음).")]
public bool bossCamRetarget = true;
[Tooltip("오빗 요(yaw) 진폭(도). sin 곡선으로 0 → +진폭 → 0 이라 끝에서 원래 각으로 돌아온다(점프 0).")]
public float bossCamOrbitDegrees = 35f;
[Tooltip("오빗 동안 궤도 거리 배율(1 = 그대로 · >1 = 멀리서 보스 전신).")]
public float bossCamDistanceScale = 1.15f;
[Tooltip("보스 스폰 때 RealCamera.arr_cCamData[1](/ ) . §F-4 3.5~4 m · 65~70° . .")]
public bool bossPresetApply = true;
[Tooltip("arr_cCamData[1].minDistance = maxDistance (m).")]
public float bossPresetDistance = 3.75f;
[Tooltip("arr_cCamData[1].cameraHeight( · 78).")]
public float bossPresetCameraHeight = 67f;
[Tooltip("arr_cCamData[1].DistanceUp. .")]
public float bossPresetDistanceUp = -1f;
// ─────────────────────────────────────────── 3타 리드
[Header("3타 카메라 리드 — ComboStage(isLast) → 이동 리드 배율")]
[Tooltip("콤보 마지막 타에 카메라 이동 리드(CameraMoveLead)를 잠시 키운다.")]
public bool comboLeadEnabled = true;
[Tooltip("리드 배율(CameraMoveLead.LeadMultiplier). 설계안 §C 811d = ×1.3.")]
public float comboLeadMultiplier = 1.3f;
[Tooltip("배율 유지 시간(초 · unscaled). 3타 클립 길이(0.42~0.47 s)에 맞춘 값.")]
public float comboLeadSeconds = 0.45f;
}
}

View File

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

View File

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

View File

@ -0,0 +1,155 @@
// ─────────────────────────────────────────────────────────────────────────────
// DamageAggregator.cs — 데미지 텍스트 합산 윈도우(0.25 s · 대상별) 데이터 API — 표시는 813g(U)
//
// PD 지시 #813 · 발주서 WL-811def §1-2 · 기준서 v1 §B 요소 2 「합산 윈도우 0.25 s · 숫자죽 방지」 (2026-09-09)
//
// ■ 무엇을 하나 (HitConfirmed 구독 · 원본 훅 0 · HUDDMGUI 무수정)
// 같은 대상에 첫 타격이 들어온 순간부터 damageMergeWindowSeconds(unscaled) 안의 타격을 한 슬롯에 더한다.
// 윈도우가 끝나면(또는 처치 타격이면 즉시) Flushed(대상 · 합계 · 타수 · 최대 단타 · 크리 여부 · 처치 여부 · 위치)를 1회 낸다.
// 813g 는 Flushed 만 구독하면 "초당 3~5개 겹치는 숫자" 대신 합산 1개를 띄울 수 있다. 원본 HUDDMGUI 는 그대로 돈다(병행).
// ■ GC 0 — 슬롯 배열(고정 · 첫 사용 때 1회 할당) · 선형 탐색(≤ 32) · 구조체 이벤트.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
/// <summary>합산 결과(윈도우 만료 또는 처치 즉시).</summary>
public struct DamageMergeEvent
{
public Actor victim; public double total; public int hits; public double maxSingle; public bool anyCritical; public bool isKill; public bool byMainPC;
public Vector3 position; public float firstTime; public float time; public int frame;
}
public static class DamageAggregator
{
public static readonly CombatEventList<DamageMergeEvent> Flushed = new CombatEventList<DamageMergeEvent>(4);
struct Slot
{
public bool used; public Actor victim; public double total, maxSingle; public int hits;
public bool anyCritical, isKill, byMainPC; public float start; public Vector3 pos;
}
static Slot[] s_slots;
static int s_used;
// ── 진단
public static bool Subscribed;
public static int HitCount, FlushCount, MaxHitsMerged, EvictCount;
public static string LastInfo = "";
public static int ActiveSlots { get { return s_used; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
Subscribed = true;
}
static WLReactionSettings St { get { return WLReactionSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg)
{
var st = St;
if (st != null && st.verboseLog) Debug.Log("[DamageAggregator] " + msg);
}
static void EnsureSlots(WLReactionSettings st)
{
int n = Mathf.Max(4, st.damageMergeSlots);
if (s_slots == null || s_slots.Length != n) { s_slots = new Slot[n]; s_used = 0; }
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!WLReactionSettings.Enabled) return;
var st = St;
if (!st.damageMergeEnabled || e.victim == null) return;
if (st.damageMergeMainPCOnly && !e.byMainPC) return;
EnsureSlots(st);
WLReactionRunner.Ensure();
float now = Time.unscaledTime;
HitCount++;
int idx = -1, free = -1, oldest = -1; float oldestStart = float.MaxValue;
for (int i = 0; i < s_slots.Length; i++)
{
if (!s_slots[i].used) { if (free < 0) free = i; continue; }
if (ReferenceEquals(s_slots[i].victim, e.victim)) { idx = i; break; }
if (s_slots[i].start < oldestStart) { oldestStart = s_slots[i].start; oldest = i; }
}
if (idx < 0)
{
if (free < 0) { Flush(oldest, now); EvictCount++; free = oldest; }
idx = free;
s_slots[idx].used = true; s_slots[idx].victim = e.victim; s_slots[idx].total = 0d; s_slots[idx].maxSingle = 0d;
s_slots[idx].hits = 0; s_slots[idx].anyCritical = false; s_slots[idx].isKill = false; s_slots[idx].byMainPC = e.byMainPC; s_slots[idx].start = now;
s_used++;
}
s_slots[idx].total += e.damage;
if (e.damage > s_slots[idx].maxSingle) s_slots[idx].maxSingle = e.damage;
s_slots[idx].hits++;
s_slots[idx].anyCritical |= e.critical;
s_slots[idx].isKill |= e.isKill;
s_slots[idx].byMainPC |= e.byMainPC;
s_slots[idx].pos = e.victim.Get_position();
if (e.isKill && st.damageMergeFlushOnKill) Flush(idx, now);
}
static void Flush(int i, float now)
{
if (i < 0 || !s_slots[i].used) return;
var ev = new DamageMergeEvent
{
victim = s_slots[i].victim, total = s_slots[i].total, hits = s_slots[i].hits, maxSingle = s_slots[i].maxSingle,
anyCritical = s_slots[i].anyCritical, isKill = s_slots[i].isKill, byMainPC = s_slots[i].byMainPC,
position = s_slots[i].pos, firstTime = s_slots[i].start, time = now, frame = Time.frameCount
};
if (ev.hits > MaxHitsMerged) MaxHitsMerged = ev.hits;
s_slots[i].used = false; s_slots[i].victim = null; s_used--;
FlushCount++;
Flushed.Dispatch(in ev);
if (Verbose) Log("플러시 victim=" + (ev.victim != null ? ev.victim.name : "?") + " total=" + ev.total.ToString("F0") + " hits=" + ev.hits + " cri=" + ev.anyCritical + " kill=" + ev.isKill);
}
internal static void Tick(float now)
{
if (s_used <= 0 || s_slots == null) return;
var st = St;
float win = st != null ? st.damageMergeWindowSeconds : 0.25f;
for (int i = 0; i < s_slots.Length; i++)
if (s_slots[i].used && now - s_slots[i].start >= win) Flush(i, now);
}
/// <summary>데이터 API — 대상의 합산 중 값(813g 가 미리 읽고 싶을 때). 슬롯이 없으면 false.</summary>
public static bool TryGetPending(Actor victim, out double total, out int hits)
{
total = 0d; hits = 0;
if (s_slots == null || victim == null) return false;
for (int i = 0; i < s_slots.Length; i++)
if (s_slots[i].used && ReferenceEquals(s_slots[i].victim, victim)) { total = s_slots[i].total; hits = s_slots[i].hits; return true; }
return false;
}
/// <summary>씬 전환 · 프로브 정리: 슬롯을 비운다(이벤트 없음).</summary>
public static void ClearAll()
{
if (s_slots == null) return;
for (int i = 0; i < s_slots.Length; i++) { s_slots[i].used = false; s_slots[i].victim = null; }
s_used = 0;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
HitCount = FlushCount = MaxHitsMerged = EvictCount = 0; LastInfo = "";
Flushed.Raised = Flushed.Dispatched = 0;
}
}
}

View File

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

View File

@ -0,0 +1,178 @@
// ─────────────────────────────────────────────────────────────────────────────
// KillChain.cs — 연쇄 처치 카운터(3 s 윈도우 · DOUBLE/TRIPLE/RAMPAGE/MASSACRE 티어) + 군집 폭발 VFX 1개
//
// PD 지시 #813 · 발주서 WL-811def §1-2 · 설계안 WL-811a §C 811e 행 · 기준서 v1 §B 요소 2 (2026-09-09)
//
// ■ 무엇을 하나 (Killed 구독 · 원본 훅 0 · HUD 표시 0 = 데이터·이벤트만 · 표시는 813g U)
// · 마지막 처치로부터 killChainWindowSeconds 안에 다음 처치가 오면 연쇄가 이어진다(아케이드 킬 스트릭 · 시간은 unscaled).
// · 연쇄 수가 tiers[i].kills 와 같아지는 순간 TierReached(티어 · 이름 · 무게중심)를 1회 낸다 · 윈도우가 끝나면 Ended.
// · 티어 인덱스 ≥ clusterBurstMinTier 면 처치 무게중심에 군집 폭발 VFX 1개(InGameInfo.Show_Effect · 풀) —
// EffectBudget.ClusterBurst 슬롯(상한 1)을 clusterBurstHoldSeconds 동안 잡아 동시 폭발을 막는다 · 화면 밖이면 스킵.
// ■ GC 0 — 위치 링 버퍼(고정 크기 · 첫 사용 때 1회 할당) · 이벤트 구조체 · 문자열은 verboseLog 때만.
// ■ 값은 전부 WLReactionSettings 에셋 · enabled=false 면 이벤트 0(C8).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
/// <summary>티어 도달 통보(813g 가 구독해 문구를 띄운다).</summary>
public struct KillChainTierEvent { public int tier; public string name; public int kills; public Vector3 centroid; public Actor lastVictim; public bool burstSpawned; public float time; public int frame; }
/// <summary>연쇄 종료(윈도우 만료) 통보.</summary>
public struct KillChainEndedEvent { public int kills; public int bestTier; public float duration; public float time; public int frame; }
public static class KillChain
{
public static readonly CombatEventList<KillChainTierEvent> TierReached = new CombatEventList<KillChainTierEvent>(4);
public static readonly CombatEventList<KillChainEndedEvent> Ended = new CombatEventList<KillChainEndedEvent>(4);
// ── 연쇄 상태
static int s_chainKills, s_bestTier = -1;
static float s_chainStart, s_lastKill = -999f;
static Vector3[] s_pos; // 최근 처치 위치 링
static int s_head, s_stored;
static Vector3 s_centroid;
// ── 폭발 슬롯
static bool s_burstHeld;
static float s_burstReleaseAt;
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int KillCount, TierCount, ChainCount, BurstCount, BurstDeniedBudget, BurstSkippedView, BurstNoInGameInfo, SkippedFilter;
public static int BestChainEver;
public static string LastInfo = "";
public static int ChainKills { get { return s_chainKills; } }
public static int BestTier { get { return s_bestTier; } }
public static Vector3 Centroid { get { return s_centroid; } }
public static bool BurstHeld { get { return s_burstHeld; } }
public static float LastKillTime { get { return s_lastKill; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.Killed.Add(OnKilled);
Subscribed = true;
}
static WLReactionSettings St { get { return WLReactionSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg)
{
var st = St;
if (st != null && st.verboseLog) Debug.Log("[KillChain] " + msg);
}
static void EnsureBuffer(WLReactionSettings st)
{
int n = Mathf.Max(4, st.killChainBufferSize);
if (s_pos == null || s_pos.Length != n) { s_pos = new Vector3[n]; s_head = 0; s_stored = 0; }
}
static void OnKilled(in KilledEvent e)
{
if (!WLReactionSettings.Enabled) return;
var st = St;
if (!st.killChainEnabled || e.victim == null) return;
if (st.killChainRequireMainPCKiller && (e.killer == null || !e.killer.IsMainPC())) { SkippedFilter++; return; }
if (!st.killChainCountSummons && e.subRole == eSubRol.Summon) { SkippedFilter++; return; }
EnsureBuffer(st);
WLReactionRunner.Ensure();
float now = Time.unscaledTime;
if (s_chainKills > 0 && now - s_lastKill >= st.killChainWindowSeconds) EndChain(now); // 늦게 온 처치 = 새 연쇄
if (s_chainKills == 0) { s_chainStart = now; s_bestTier = -1; s_stored = 0; s_head = 0; ChainCount++; }
s_chainKills++;
s_lastKill = now;
KillCount++;
// 위치 링 + 무게중심(연쇄 안 최근 N개)
s_pos[s_head] = e.position;
s_head = (s_head + 1) % s_pos.Length;
if (s_stored < s_pos.Length) s_stored++;
Vector3 sum = Vector3.zero;
for (int i = 0; i < s_stored; i++) sum += s_pos[i];
s_centroid = sum / s_stored;
// 티어 판정(오름차순 · kills 와 정확히 같아지는 순간 1회)
var tiers = st.tiers;
if (tiers == null) return;
for (int i = 0; i < tiers.Length; i++)
{
if (tiers[i].kills != s_chainKills) continue;
s_bestTier = i;
if (s_chainKills > BestChainEver) BestChainEver = s_chainKills;
bool burst = i >= st.clusterBurstMinTier && TrySpawnBurst(st, now);
var ev = new KillChainTierEvent { tier = i, name = tiers[i].name, kills = s_chainKills, centroid = s_centroid, lastVictim = e.victim, burstSpawned = burst, time = now, frame = Time.frameCount };
TierCount++;
LastInfo = tiers[i].name;
TierReached.Dispatch(in ev);
if (Verbose) Log("티어 " + tiers[i].name + " kills=" + s_chainKills + " centroid=" + s_centroid.ToString("F1") + " burst=" + burst);
break;
}
if (s_chainKills > BestChainEver) BestChainEver = s_chainKills;
}
static bool TrySpawnBurst(WLReactionSettings st, float now)
{
if (!st.clusterBurstEnabled) return false;
if (st.clusterBurstRequireInView && !EffectBudget.IsInView(s_centroid)) { BurstSkippedView++; return false; }
if (s_burstHeld && now < s_burstReleaseAt) { BurstDeniedBudget++; return false; } // 우리 슬롯이 아직 살아 있음(동시 ≤ 1)
if (!EffectBudget.TryAcquire(EffectBudgetKind.ClusterBurst)) { BurstDeniedBudget++; return false; }
s_burstHeld = true;
s_burstReleaseAt = now + Mathf.Max(0.05f, st.clusterBurstHoldSeconds);
var info = InGameInfo.Ins;
if (info == null) { BurstNoInGameInfo++; if (Verbose) Log("폭발 스킵(InGameInfo 없음 · 슬롯은 잡음)"); return false; }
info.Show_Effect(st.clusterBurstPrefab, s_centroid + Vector3.up * st.clusterBurstHeightOffset);
BurstCount++;
return true;
}
/// <summary>폭발 슬롯을 돌려준다(홀드 만료 · 러너 파괴 · 씬 전환).</summary>
public static void ReleaseBurst()
{
if (!s_burstHeld) return;
s_burstHeld = false;
EffectBudget.Release(EffectBudgetKind.ClusterBurst);
}
static void EndChain(float now)
{
if (s_chainKills <= 0) return;
var ev = new KillChainEndedEvent { kills = s_chainKills, bestTier = s_bestTier, duration = now - s_chainStart, time = now, frame = Time.frameCount };
if (Verbose) Log("연쇄 종료 kills=" + s_chainKills + " best=" + s_bestTier + " dur=" + ev.duration.ToString("F2"));
s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0;
Ended.Dispatch(in ev);
}
internal static void Tick(float now)
{
if (s_chainKills > 0)
{
var st = St;
float win = st != null ? st.killChainWindowSeconds : 3f;
if (now - s_lastKill >= win) EndChain(now);
}
if (s_burstHeld && now >= s_burstReleaseAt) ReleaseBurst();
}
/// <summary>씬 전환 · 프로브 정리: 연쇄와 슬롯을 비운다(이벤트 없음).</summary>
public static void ClearAll()
{
s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0; s_lastKill = -999f;
ReleaseBurst();
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
KillCount = TierCount = ChainCount = BurstCount = BurstDeniedBudget = BurstSkippedView = BurstNoInGameInfo = SkippedFilter = 0;
BestChainEver = 0; LastInfo = "";
TierReached.Raised = TierReached.Dispatched = 0; Ended.Raised = Ended.Dispatched = 0;
}
}
}

View File

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

View File

@ -0,0 +1,325 @@
// ─────────────────────────────────────────────────────────────────────────────
// MobHitFlash.cs — 몹 피격 점멸(MaterialPropertyBlock · 머티리얼 인스턴스 0 · GC 0) + 처치 디졸브 엣지 세팅
//
// PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d(주석 처리된 Actor.HitEffect_Color 의 MPB 복원) · 설계안 §C 811f 행 (2026-09-09)
//
// ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · 원본 훅 0 · Actor.cs/MobActor.cs 무수정)
// HitConfirmed(몹) → 렌더러별 MPB 에 알베도 색(HDR 흰색) + 에미션을 써서 blinkOnSeconds 뒤 비운다.
// 같은 프레임 다중 히트(스윙 1회 5~7타)는 켜진 동안 연장 · 꺼진 뒤 blinkOffSeconds 안은 재점멸 안 함.
// Killed(몹) → 같은 MPB 에 디졸브 엣지 세기/범위(등급별)를 얹는다. 0.5 s 뒤 원본 Co_Dissolve 가 렌더러 머티리얼을
// 디졸브 머티리얼로 바꾸는 순간 그 프로퍼티가 적용된다(MPB 는 렌더러에 붙어 머티리얼 교체와 무관).
// Spawned(몹) → 풀 재사용 몹의 MPB 를 비운다(잔류 0).
// ■ 왜 원본 방식(머티리얼 Add/Remove)이 아니라 MPB 인가
// 원본 HitEffect_Color 는 sharedMaterials.ToList()+materials= 대입으로 매 타격 2.3 KB GC + 머티리얼 인스턴스가 생겨 주석 처리됐다.
// MPB 는 렌더러당 1개를 캐시해 재사용하므로 타격당 할당 0 · 머티리얼 인스턴스 0 · 드로우 +0(프로퍼티만).
// 대신 MPB 가 붙은 렌더러는 그 동안 SRP Batcher 에서 빠진다(≤ 0.03 s · 비운 뒤 복귀 · 디졸브 중 사망체 ≤ 6).
// ■ 렌더러 캐시 = Actor.arr_Renderer(protected · Awake 에서 ParticleSystemRenderer 를 뺀 캐시)를 리플렉션으로 1회 읽는다(원본 무수정 ·
// 같은 필터 결과). 필드를 못 찾으면 GetComponentsInChildren 로 같은 필터를 1회 적용. 액터당 상태는 처음 볼 때 1회만 할당(풀이라 유한).
// ■ 프로퍼티는 셰이더별로 1회 Shader.FindPropertyIndex 로 실측해 캐시 · 없으면 그 렌더러는 건너뛴다(예외 0).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
public static class MobHitFlash
{
struct Resolved { public bool hasColor, hasEmission; public int colorId, emissionId; }
sealed class State
{
public Actor actor;
public Renderer[] renderers;
public MaterialPropertyBlock[] blocks;
public Resolved[] resolved;
public bool blinkOn, tint;
public float blinkOffAt, blinkReadyAt, tintHdr, tintRange;
public int activeIndex = -1;
}
static readonly Dictionary<Actor, State> s_states = new Dictionary<Actor, State>(64);
static readonly Dictionary<Shader, Resolved> s_byShader = new Dictionary<Shader, Resolved>(8);
static State[] s_active = new State[32];
static int s_activeCount;
static FieldInfo s_rendererField;
static bool s_rendererFieldLooked;
static bool s_dissolveResolved, s_dissolveHasHdr, s_dissolveHasRange;
static int s_edgeHdrId, s_edgeRangeId;
static readonly List<Renderer> s_tmpRenderers = new List<Renderer>(16);
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int OnCount, OffCount, ExtendCount, ThrottledCount, TintCount, ClearCount, SkippedFilter, SkippedNoProperty, StatesCreated, RendererFieldViaReflection;
public static string LastInfo = "";
public static int ActiveCount { get { return s_activeCount; } }
public static int StateCount { get { return s_states.Count; } }
public static bool DissolveResolved { get { return s_dissolveResolved; } }
public static bool DissolveHasHdr { get { return s_dissolveHasHdr; } }
public static bool DissolveHasRange { get { return s_dissolveHasRange; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
Subscribed = true;
}
static WLHitBlinkSettings St { get { return WLHitBlinkSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg)
{
var st = St;
if (st != null && st.verboseLog) Debug.Log("[MobHitFlash] " + msg);
}
// ───────────────────────────────────────── 이벤트
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!WLHitBlinkSettings.Enabled) return;
var st = St;
var victim = e.victim;
if (victim == null) return;
if (st.targetMobsOnly ? !(victim is MobActor) : victim.IsMainPC()) { SkippedFilter++; return; }
if (st.onlyByMainPC && !e.byMainPC) { SkippedFilter++; return; }
if (st.skipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; }
var s = GetState(victim, st);
if (s == null) return;
float now = Time.unscaledTime;
if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장
if (now < s.blinkReadyAt) { ThrottledCount++; return; }
if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리
WLReactionRunner.Ensure();
s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds;
Apply(s, st);
Activate(s);
OnCount++;
LastInfo = "on";
if (Verbose) Log("점멸 on " + victim.name + " renderers=" + s.renderers.Length);
}
static void OnKilled(in KilledEvent e)
{
if (!WLHitBlinkSettings.Enabled) return;
var st = St;
if (!st.dissolveEdgeEnabled) return;
var victim = e.victim;
if (victim == null || !(victim is MobActor)) return;
ResolveDissolve(st);
if (!s_dissolveHasHdr && !s_dissolveHasRange) { SkippedNoProperty++; return; }
var s = GetState(victim, st);
if (s == null) return;
float hdr = e.subRole == eSubRol.Boss ? st.dissolveEdgeHdrBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeHdrElite : st.dissolveEdgeHdrNormal;
float range = e.subRole == eSubRol.Boss ? st.dissolveEdgeRangeBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeRangeElite : st.dissolveEdgeRangeNormal;
if (hdr < 0f && range < 0f) return;
s.tint = true; s.tintHdr = hdr; s.tintRange = range;
Apply(s, st);
TintCount++;
if (Verbose) Log("디졸브 엣지 " + victim.name + " sub=" + e.subRole + " hdr=" + hdr + " range=" + range);
}
static void OnSpawned(in SpawnedEvent e)
{
if (e.actor == null) return;
State s;
if (!s_states.TryGetValue(e.actor, out s)) return;
if (!s.blinkOn && !s.tint) return;
s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f;
var st = St;
if (st != null) Apply(s, st);
Deactivate(s);
ClearCount++;
}
// ───────────────────────────────────────── 상태
static State GetState(Actor actor, WLHitBlinkSettings st)
{
State s;
if (s_states.TryGetValue(actor, out s)) return s;
var renderers = FindRenderers(actor);
if (renderers == null || renderers.Length == 0) return null;
s = new State { actor = actor, renderers = renderers, blocks = new MaterialPropertyBlock[renderers.Length], resolved = new Resolved[renderers.Length] };
for (int i = 0; i < renderers.Length; i++)
{
s.blocks[i] = new MaterialPropertyBlock();
var r = renderers[i];
var mat = r != null ? r.sharedMaterial : null;
s.resolved[i] = Resolve(mat != null ? mat.shader : null, st);
}
s_states.Add(actor, s);
StatesCreated++;
return s;
}
/// <summary>Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다. 실패하면 같은 필터로 직접 수집.</summary>
static Renderer[] FindRenderers(Actor actor)
{
if (!s_rendererFieldLooked)
{
s_rendererFieldLooked = true;
s_rendererField = typeof(Actor).GetField("arr_Renderer", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
}
if (s_rendererField != null)
{
var arr = s_rendererField.GetValue(actor) as Renderer[];
if (arr != null && arr.Length > 0) { RendererFieldViaReflection++; return arr; }
}
s_tmpRenderers.Clear();
actor.GetComponentsInChildren<Renderer>(true, s_tmpRenderers);
int n = 0;
for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) n++;
var result = new Renderer[n];
n = 0;
for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) result[n++] = s_tmpRenderers[i];
s_tmpRenderers.Clear();
return result;
}
static Resolved Resolve(Shader shader, WLHitBlinkSettings st)
{
Resolved r;
if (shader == null) return default(Resolved);
if (s_byShader.TryGetValue(shader, out r)) return r;
r = default(Resolved);
var names = st.colorProperties;
if (names != null)
for (int i = 0; i < names.Length; i++)
if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasColor = true; r.colorId = Shader.PropertyToID(names[i]); break; }
names = st.emissionProperties;
if (names != null)
for (int i = 0; i < names.Length; i++)
if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasEmission = true; r.emissionId = Shader.PropertyToID(names[i]); break; }
s_byShader.Add(shader, r);
if (!r.hasColor && !r.hasEmission) SkippedNoProperty++;
if (Verbose) Log("셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission);
return r;
}
static void ResolveDissolve(WLHitBlinkSettings st)
{
if (s_dissolveResolved) return;
Shader sh = null;
var info = InGameInfo.Ins;
if (info != null && info.mat_Dissolve != null) sh = info.mat_Dissolve.shader;
if (sh == null && !string.IsNullOrEmpty(st.dissolveShaderName)) sh = Shader.Find(st.dissolveShaderName);
if (sh == null) return; // 아직 못 찾음(타이틀 등) — 다음 Killed 때 재시도
s_dissolveResolved = true;
s_dissolveHasHdr = !string.IsNullOrEmpty(st.dissolveEdgeHdrProperty) && sh.FindPropertyIndex(st.dissolveEdgeHdrProperty) >= 0;
s_dissolveHasRange = !string.IsNullOrEmpty(st.dissolveEdgeRangeProperty) && sh.FindPropertyIndex(st.dissolveEdgeRangeProperty) >= 0;
if (s_dissolveHasHdr) s_edgeHdrId = Shader.PropertyToID(st.dissolveEdgeHdrProperty);
if (s_dissolveHasRange) s_edgeRangeId = Shader.PropertyToID(st.dissolveEdgeRangeProperty);
if (Verbose) Log("디졸브 셰이더 실측 " + sh.name + " hdr=" + s_dissolveHasHdr + " range=" + s_dissolveHasRange);
}
/// <summary>상태(blinkOn · tint)로 렌더러별 MPB 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 빈 블록 = 오버라이드 0.</summary>
static void Apply(State s, WLHitBlinkSettings st)
{
for (int i = 0; i < s.renderers.Length; i++)
{
var r = s.renderers[i];
if (r == null) continue;
var mpb = s.blocks[i];
mpb.Clear();
if (s.blinkOn)
{
var res = s.resolved[i];
if (res.hasColor) mpb.SetColor(res.colorId, st.blinkColor);
if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.blinkEmission);
}
if (s.tint)
{
if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr);
if (s_dissolveHasRange && s.tintRange >= 0f) mpb.SetFloat(s_edgeRangeId, s.tintRange);
}
r.SetPropertyBlock(mpb);
}
}
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;
var st = St;
for (int i = s_activeCount - 1; i >= 0; i--)
{
var s = s_active[i];
if (s.blinkOn && (now >= s.blinkOffAt || st == null || !WLHitBlinkSettings.Enabled))
{
s.blinkOn = false;
s.blinkReadyAt = now + (st != null ? st.blinkOffSeconds : 0f);
if (st != null) Apply(s, st);
OffCount++;
LastInfo = "off";
}
if (!s.blinkOn) Deactivate(s);
}
}
/// <summary>모든 몹의 MPB 를 비운다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지.</summary>
public static void RestoreAll()
{
var st = St;
foreach (var kv in s_states)
{
var s = kv.Value;
if (!s.blinkOn && !s.tint) continue;
s.blinkOn = false; s.tint = false;
if (st != null) Apply(s, st);
else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(s.blocks[i]); }
}
for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; }
s_activeCount = 0;
}
/// <summary>씬 전환: MPB 를 비우고 상태 캐시(파괴된 Actor 참조)를 버린다.</summary>
public static void ClearAll()
{
RestoreAll();
s_states.Clear();
}
/// <summary>프로브용: 특정 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사).</summary>
public static bool HasAnyBlock(Actor actor)
{
State s;
if (actor == null || !s_states.TryGetValue(actor, out s)) return false;
for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null && s.renderers[i].HasPropertyBlock()) return true;
return false;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
OnCount = OffCount = ExtendCount = ThrottledCount = TintCount = ClearCount = SkippedFilter = SkippedNoProperty = StatesCreated = RendererFieldViaReflection = 0;
LastInfo = "";
}
}
}

View File

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

View File

@ -0,0 +1,124 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLHitBlinkSettings.cs — 몹 피격 점멸(MaterialPropertyBlock) · 처치 디졸브 엣지 값의 단일 출처(SOT · C45)
//
// PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d §3 ⓕ 규격 · 설계안 §C 811f 행 · 기준서 v1 §B 요소 2 (2026-09-09)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset
// → Resources.Load<WLHitBlinkSettings>("WL/WLHitBlinkSettings") 로 1회 로드 후 캐시.
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 MobHitFlash 가 아무 것도 하지 않는다(C8 · 원본 = 점멸 꺼짐 상태 그대로).
//
// 【프로퍼티명 실측 · 2026-09-09 · Assets/Res_Addr/Mobs/{Mob,Elite,FieldBoss} 299 프리팹 · 머티리얼 참조 355】
// 312 = MK Toon `Assets/_MK/MKToon/Shader/URP/Standard/StandardSimple.shader` → [MainColor] _AlbedoColor · _EmissionColor
// (_EmissionColor 는 Config.hlsl 이 MK_EMISSION 을 항상 정의해 키워드 없이 가산된다 · _BaseColor 없음 · _Color 는 HideInInspector 미사용)
// 나머지 41 = EricWang Alpha Blended / Additive · MasterStylizedFX CommonParticleOneShot(파티클 · arr_Renderer 필터 대상 밖)
// 디졸브 = `ThirdParty/Dissolve Edge by Moonflower Carnivore/Shaders/Transparent_Unlit_DissolveEdge.shader`("MoonflowerCarnivore/Dissolve Edge")
// → 엣지 **색** 프로퍼티는 없고 램프 텍스처(_EdgeAroundRamp) × _EdgeAroundHDR(1~3) · _EdgeAround(범위 0~0.5) 가 색·세기를 정한다.
// _Progress 는 원본 Co_Dissolve 가 sharedMaterial.SetFloat 로 구동하므로 MPB 에 넣지 않는다(넣으면 디졸브가 멈춘다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Reaction
{
[CreateAssetMenu(fileName = "WLHitBlinkSettings", menuName = "WL/Hit Blink Settings", order = 323)]
public sealed class WLHitBlinkSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLHitBlinkSettings";
static WLHitBlinkSettings s_cached;
static bool s_lookupDone;
public static WLHitBlinkSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLHitBlinkSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 점멸만 끈다. Play 종료 시 자동 false.</summary>
public static bool RuntimeDisabled;
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 점멸·디졸브 엣지 세팅이 전부 비활성(MPB 0) = 원본(점멸 꺼짐) 동작. 롤백 1순위.")]
public bool enabled = true;
[Tooltip("점멸 on/off · 엣지 세팅을 Console 에 남긴다(검증용 · GC 발생).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── 대상
[Header("대상")]
[Tooltip("몬스터(MobActor 계열)만 점멸. off 면 PC 를 뺀 모든 Actor.")]
public bool targetMobsOnly = true;
[Tooltip("메인 PC 가 때린 것만 점멸. off 면 펫·소환수 타격도 점멸.")]
public bool onlyByMainPC = false;
[Tooltip("피해 0(무적·회피)은 점멸하지 않는다.")]
public bool skipWhenDamageZero = true;
// ─────────────────────────────────────────── 점멸
[Header("점멸 (HitConfirmed → MPB on → blinkOnSeconds 뒤 off)")]
[Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.")]
[ColorUsage(true, true)] public Color blinkColor = new Color(2.2f, 2.2f, 2.2f, 1f);
[Tooltip("에미션 프로퍼티도 함께 올린다(MK Toon 은 키워드 없이 가산 · URP Lit 은 _EMISSION 키워드가 켜진 머티리얼만).")]
public bool useEmission = true;
[Tooltip("점멸 에미션 색(HDR).")]
[ColorUsage(true, true)] public Color blinkEmission = new Color(0.6f, 0.6f, 0.6f, 1f);
[Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")]
public float blinkOnSeconds = 0.03f;
[Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")]
public float blinkOffSeconds = 0.03f;
[Tooltip("알베도/베이스 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것을 쓴다 · Shader.FindPropertyIndex 실측). 없으면 그 렌더러는 건너뜀.")]
public string[] colorProperties = new string[] { "_AlbedoColor", "_BaseColor" };
[Tooltip("에미션 색 프로퍼티 후보.")]
public string[] emissionProperties = new string[] { "_EmissionColor" };
// ─────────────────────────────────────────── 디졸브 엣지
[Header("처치 디졸브 엣지 (Killed → MPB 로 엣지 세기·범위 · 원본 Co_Dissolve 무수정)")]
[Tooltip("처치 등급별 디졸브 엣지 세기/범위를 MPB 로 미리 얹는다(0.5 s 뒤 원본이 디졸브 머티리얼로 바꾸는 순간 적용).")]
public bool dissolveEdgeEnabled = true;
[Tooltip("디졸브 셰이더 이름(InGameInfo.mat_Dissolve 가 없을 때 Shader.Find 폴백 · 실측 \"MoonflowerCarnivore/Dissolve Edge\").")]
public string dissolveShaderName = "MoonflowerCarnivore/Dissolve Edge";
[Tooltip("엣지 세기 프로퍼티(실측 _EdgeAroundHDR · 머티리얼 기본 1.8 · 범위 1~3).")]
public string dissolveEdgeHdrProperty = "_EdgeAroundHDR";
[Tooltip("엣지 범위 프로퍼티(실측 _EdgeAround · 머티리얼 기본 0.157 · 범위 0~0.5).")]
public string dissolveEdgeRangeProperty = "_EdgeAround";
[Tooltip("일반 몹 엣지 세기(음수 = 머티리얼 값 유지).")]
public float dissolveEdgeHdrNormal = 2f;
[Tooltip("엘리트 엣지 세기.")]
public float dissolveEdgeHdrElite = 2.5f;
[Tooltip("보스 엣지 세기.")]
public float dissolveEdgeHdrBoss = 3f;
[Tooltip("일반 몹 엣지 범위(음수 = 머티리얼 값 유지).")]
public float dissolveEdgeRangeNormal = -1f;
[Tooltip("엘리트 엣지 범위.")]
public float dissolveEdgeRangeElite = 0.22f;
[Tooltip("보스 엣지 범위.")]
public float dissolveEdgeRangeBoss = 0.3f;
}
}

View File

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

View File

@ -0,0 +1,48 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLReactionRunner.cs — 전투 반응(연쇄 처치 · 데미지 합산 · 몹 점멸) 공용 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad)
//
// 발주서 WL-811def · 새 Manager/Singleton 을 만들지 않는다(WLHitFeel __WLHitFeel · TimeScaleArbiter 러너 선례). 코루틴 없음 · GC 0.
// 각 시스템은 처음 틱이 필요할 때 Ensure() 를 부르고, Update 는 unscaledTime 하나를 세 시스템에 넘긴다.
// 파괴·비활성·앱 종료 시 몹 MPB 를 원복하고 폭발 슬롯을 돌려준다. 씬 전환 시 세 시스템의 상태를 비운다(파괴된 Actor 참조 방지).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.SceneManagement;
namespace WL.Combat.Reaction
{
public sealed class WLReactionRunner : MonoBehaviour
{
static WLReactionRunner s_runner;
static bool s_sceneHooked;
public static bool Exists { get { return s_runner != null; } }
public static void Ensure()
{
if (s_runner != null) return;
var go = new GameObject("__WLReaction");
DontDestroyOnLoad(go);
s_runner = go.AddComponent<WLReactionRunner>();
if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; }
}
static void OnSceneChanged(Scene from, Scene to)
{
KillChain.ClearAll();
DamageAggregator.ClearAll();
MobHitFlash.ClearAll();
}
void Update()
{
float now = Time.unscaledTime;
KillChain.Tick(now);
DamageAggregator.Tick(now);
MobHitFlash.Tick(now);
}
void OnDisable() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); }
void OnApplicationQuit() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); }
}
}

View File

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

View File

@ -0,0 +1,129 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLReactionSettings.cs — 전투 반응(연쇄 처치 · 군집 폭발 · 데미지 합산 윈도우) 값의 단일 출처(SOT · C45)
//
// PD 지시 #813 · 발주서 WL-811def §1-2 · 설계안 WL-811a §C 811e 행 · 기준서 v1 §B 요소 2 (2026-09-09)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset
// → Resources.Load<WLReactionSettings>("WL/WLReactionSettings") 로 1회 로드 후 캐시.
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 KillChain · DamageAggregator 가 아무 것도 하지 않는다(C8).
// HUD 표시는 하지 않는다 — 이 파일의 시스템은 이벤트(KillChain.TierReached · DamageAggregator.Flushed)만 낸다(표시 = 813g U).
// ─────────────────────────────────────────────────────────────────────────────
using System;
using UnityEngine;
namespace WL.Combat.Reaction
{
[Serializable]
public struct KillChainTier
{
[Tooltip("티어 이름(데이터 · HUD 문구는 813g 가 이 이름을 키로 쓴다).")]
public string name;
[Tooltip("이 티어에 도달하는 연쇄 처치 수.")]
public int kills;
}
[CreateAssetMenu(fileName = "WLReactionSettings", menuName = "WL/Reaction Settings", order = 322)]
public sealed class WLReactionSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLReactionSettings";
static WLReactionSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLReactionSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLReactionSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 이 반응만 끈다. Play 종료 시 자동 false.</summary>
public static bool RuntimeDisabled;
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 연쇄 처치 카운터 · 군집 폭발 · 데미지 합산이 전부 비활성(이벤트 0). 롤백 1순위.")]
public bool enabled = true;
[Tooltip("티어 도달 · 폭발 · 합산 플러시를 Console 에 남긴다(검증용 · GC 발생).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── 연쇄 처치
[Header("연쇄 처치 (Killed · 윈도우 안 연속 처치 카운터)")]
[Tooltip("연쇄 처치 카운터 사용 여부.")]
public bool killChainEnabled = true;
[Tooltip("연쇄 유지 시간(초 · unscaled). 마지막 처치로부터 이 시간 안에 다음 처치가 오면 연쇄가 이어진다. 기준서 §B 요소 2 = 3 s.")]
public float killChainWindowSeconds = 3f;
[Tooltip("메인 PC 가 직접 처치한 것만 센다(killer == 메인 PC). off 면 도트·펫·소환수 처치도 센다.")]
public bool killChainRequireMainPCKiller = false;
[Tooltip("몬스터 소환수(eSubRol.Summon) 처치도 센다.")]
public bool killChainCountSummons = true;
[Tooltip("연쇄 위치(무게중심) 계산에 기억하는 최근 처치 수(버퍼 용량 · 첫 사용 때 할당).")]
public int killChainBufferSize = 16;
[Tooltip("티어 표(오름차순). 연쇄 수가 kills 와 같아지는 순간 TierReached 를 1회 낸다. 기준서 제안 2/3/5/8.")]
public KillChainTier[] tiers = new KillChainTier[]
{
new KillChainTier { name = "DOUBLE", kills = 2 },
new KillChainTier { name = "TRIPLE", kills = 3 },
new KillChainTier { name = "RAMPAGE", kills = 5 },
new KillChainTier { name = "MASSACRE", kills = 8 },
};
// ─────────────────────────────────────────── 군집 폭발
[Header("군집 폭발 (티어 도달 → 처치 무게중심에 VFX 1개 · EffectBudget.ClusterBurst 상한 1)")]
[Tooltip("군집 폭발 사용 여부.")]
public bool clusterBurstEnabled = true;
[Tooltip("폭발을 내는 최소 티어 인덱스(tiers 배열 기준 · 0 = DOUBLE · 1 = TRIPLE).")]
public int clusterBurstMinTier = 1;
[Tooltip("Assets/Res_Addr/Effect/<이름>.prefab — InGameInfo.Show_Effect 로 풀에서 꺼낸다(에셋 인벤토리 실존 확인 = BoomHit · 대안 FireballHit · Effect_STR_EXTRA_SPLASH).")]
public string clusterBurstPrefab = "BoomHit";
[Tooltip("폭발 슬롯(EffectBudget.ClusterBurst)을 잡아 두는 시간(초 · unscaled). 이 시간 안의 다음 폭발은 스킵(동시 ≤ 1).")]
public float clusterBurstHoldSeconds = 1f;
[Tooltip("무게중심에서 위로 띄우는 높이(m).")]
public float clusterBurstHeightOffset = 0.3f;
[Tooltip("무게중심이 화면 밖(EffectBudget.IsInView)이면 스킵.")]
public bool clusterBurstRequireInView = true;
// ─────────────────────────────────────────── 데미지 합산
[Header("데미지 합산 윈도우 (HitConfirmed → 대상별 합산 → Flushed 이벤트 · 표시는 813g)")]
[Tooltip("합산 사용 여부.")]
public bool damageMergeEnabled = true;
[Tooltip("첫 타격부터 이 시간(초 · unscaled) 안의 같은 대상 타격을 하나로 합쳐 Flushed 를 낸다. 기준서 §B 요소 2 = 0.25 s.")]
public float damageMergeWindowSeconds = 0.25f;
[Tooltip("처치 타격(isKill)은 윈도우를 기다리지 않고 즉시 플러시한다.")]
public bool damageMergeFlushOnKill = true;
[Tooltip("동시에 합산 중일 수 있는 대상 수(슬롯 · 첫 사용 때 할당). 초과하면 가장 오래된 슬롯을 먼저 플러시한다.")]
public int damageMergeSlots = 32;
[Tooltip("메인 PC 가 때린 것만 합산한다. off 면 모든 타격(PC 피격 포함)을 대상별로 합산.")]
public bool damageMergeMainPCOnly = false;
}
}

View File

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

View File

@ -0,0 +1,39 @@
%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: cc36afee831a43ba801df0563266287a, type: 3}
m_Name: WLHitBlinkSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLHitBlinkSettings
enabled: 1
verboseLog: 0
targetMobsOnly: 1
onlyByMainPC: 0
skipWhenDamageZero: 1
blinkColor: {r: 2.2, g: 2.2, b: 2.2, a: 1}
useEmission: 1
blinkEmission: {r: 0.6, g: 0.6, b: 0.6, a: 1}
blinkOnSeconds: 0.03
blinkOffSeconds: 0.03
colorProperties:
- _AlbedoColor
- _BaseColor
emissionProperties:
- _EmissionColor
dissolveEdgeEnabled: 1
dissolveShaderName: MoonflowerCarnivore/Dissolve Edge
dissolveEdgeHdrProperty: _EdgeAroundHDR
dissolveEdgeRangeProperty: _EdgeAround
dissolveEdgeHdrNormal: 2
dissolveEdgeHdrElite: 2.5
dissolveEdgeHdrBoss: 3
dissolveEdgeRangeNormal: -1
dissolveEdgeRangeElite: 0.22
dissolveEdgeRangeBoss: 0.3

View File

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

View File

@ -0,0 +1,41 @@
%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: acd8e1332fa14f8f8d4d07b310526841, type: 3}
m_Name: WLReactionSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLReactionSettings
enabled: 1
verboseLog: 0
killChainEnabled: 1
killChainWindowSeconds: 3
killChainRequireMainPCKiller: 0
killChainCountSummons: 1
killChainBufferSize: 16
tiers:
- name: DOUBLE
kills: 2
- name: TRIPLE
kills: 3
- name: RAMPAGE
kills: 5
- name: MASSACRE
kills: 8
clusterBurstEnabled: 1
clusterBurstMinTier: 1
clusterBurstPrefab: BoomHit
clusterBurstHoldSeconds: 1
clusterBurstHeightOffset: 0.3
clusterBurstRequireInView: 1
damageMergeEnabled: 1
damageMergeWindowSeconds: 0.25
damageMergeFlushOnKill: 1
damageMergeSlots: 32
damageMergeMainPCOnly: 0

View File

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

View File

@ -0,0 +1,230 @@
// ─────────────────────────────────────────────────────────────────────────────
// KillChainText.cs — 연쇄 처치 문구 표시 (WL-813g §1-1 · #813)
//
// 기준서 v1 §B 요소 2 「처치 연출 · 연쇄 처치 3 s 윈도우 2/3/5/8킬」 · 발주서 §1-1
// 「811e KillChain 티어 이벤트(DOUBLE/TRIPLE/RAMPAGE) 구독 → 화면 중앙 상단 0.8 s 팝(스케일 펀치)」.
//
// ■ 데이터/표시 분리
// 카운터·윈도우·티어 표는 전부 811e(`WL.Combat.Reaction.KillChain` + `WLReactionSettings.tiers`)가 갖는다.
// 이 파일은 `KillChain.TierReached` 를 **구독만** 하고 문구를 띄운다(원본 훅 0 · Gameplay 코드 0).
//
// ■ 문구 톤 — PD Q5 전까지 임시 영문(발주서 §1-1). 형식·색·크기는 전부 WLCombatTextSettings 에셋.
//
// ■ Safe Area (813c 규칙)
// 이 컴포넌트는 이미 SafeAreaFitter 가 붙은 `IngameUIs/WL_HUD` 아래에 놓인다 →
// 부모가 안전 영역이므로 여기서는 **부모 기준 중앙 상단 오프셋**만 계산한다(Screen.safeArea 재계산 0).
//
// ■ 롤백(C8)
// WLCombatTextSettings 가 없거나 textEnabled/killChainTextEnabled = false 면 구독조차 하지 않는다(표시 0).
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using WL.Combat.Reaction;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class KillChainText : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform box;
[SerializeField] private TextMeshProUGUI mainLabel;
[SerializeField] private TextMeshProUGUI subLabel;
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private float _life, _lifeMax, _elapsed;
private bool _subscribed;
// ── 진단(프로브가 읽는다 · 실측만)
public int TierSeen { get; private set; }
public int LastTier { get; private set; }
public int LastKills { get; private set; }
public string LastText { get; private set; }
public bool Visible { get { return group != null && group.alpha > 0f; } }
public bool Subscribed { get { return _subscribed; } }
public float LastScale { get; private set; }
public Vector2 LastAnchoredPos { get; private set; }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>에디트 모드에서도 런타임과 같은 경로를 타게 OnEnable 본체를 분리한다(813c 교훈).</summary>
public string Initialize()
{
_rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
Hide();
var s = WLCombatTextSettings.Instance;
bool on = WLCombatTextSettings.Enabled && s != null && s.killChainTextEnabled;
Subscribe(on);
return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
if (on) KillChain.TierReached.Add(OnTier);
else KillChain.TierReached.Remove(OnTier);
_subscribed = on;
}
/// <summary>필요한 자식(박스 · 본문 · 부제)을 만든다. 이미 있으면 그대로 쓴다.</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
bool made = false;
if (group == null)
{
group = GetComponent<CanvasGroup>();
if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; }
group.interactable = false; group.blocksRaycasts = false;
}
if (box == null) { box = WLTextFxUtil.NewChild(_rt, "Box"); made = true; }
if (mainLabel == null) { mainLabel = WLTextFxUtil.NewText(box, "Main", font, TextAlignmentOptions.Center); made = true; }
if (subLabel == null) { subLabel = WLTextFxUtil.NewText(box, "Sub", font, TextAlignmentOptions.Center); made = true; }
return made;
}
/// <summary>설정 값대로 위치·크기를 잡는다(px → 유닛 환산은 캔버스에서 구한다 · 상수 0).</summary>
public string ApplyLayout()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀";
if (_rt == null) _rt = GetComponent<RectTransform>();
if (box == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = WLTextFxUtil.UnitsPerPx(this, s);
// 루트는 부모(Safe Area 레이어)를 가득 채운다 — 좌표 계산의 기준이 안전 영역이 되게.
WLTextFxUtil.Stretch(_rt);
// 박스 = 화면 중앙 기준으로 위로 killChainTopOffsetPx.
box.anchorMin = box.anchorMax = new Vector2(0.5f, 0.5f);
box.pivot = new Vector2(0.5f, 0.5f);
box.sizeDelta = new Vector2(s.designWidthPx * u, (s.killChainFontPx * 2.4f) * u);
box.anchoredPosition = new Vector2(0f, s.killChainTopOffsetPx * u);
LastAnchoredPos = box.anchoredPosition;
mainLabel.rectTransform.anchorMin = new Vector2(0f, 0.5f);
mainLabel.rectTransform.anchorMax = new Vector2(1f, 1f);
mainLabel.rectTransform.offsetMin = Vector2.zero;
mainLabel.rectTransform.offsetMax = Vector2.zero;
mainLabel.fontSize = s.killChainFontPx * u;
mainLabel.alignment = TextAlignmentOptions.Bottom;
subLabel.rectTransform.anchorMin = new Vector2(0f, 0f);
subLabel.rectTransform.anchorMax = new Vector2(1f, 0.5f);
subLabel.rectTransform.offsetMin = Vector2.zero;
subLabel.rectTransform.offsetMax = Vector2.zero;
subLabel.fontSize = s.killChainFontPx * u;
subLabel.alignment = TextAlignmentOptions.Top;
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" fontPx=" + s.killChainFontPx + "→" + mainLabel.fontSize.ToString("F1");
}
// ───────────────────────────────────────────── 이벤트
private void OnTier(in KillChainTierEvent e)
{
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.killChainTextEnabled) return;
Show(e.tier, e.name, e.kills, s);
}
/// <summary>티어 문구를 띄운다(합성 이벤트·프로브도 이 경로를 쓴다).</summary>
public string Show(int tier, string tierName, int kills, WLCombatTextSettings s)
{
if (s == null) s = WLCombatTextSettings.Instance;
if (s == null) return "설정 없음";
BuildIfNeeded();
ApplyLayout();
TierSeen++; LastTier = tier; LastKills = kills;
string main = string.IsNullOrEmpty(s.killChainFormat) ? tierName : DSUtil.Format(s.killChainFormat, tierName, kills);
string sub = string.IsNullOrEmpty(s.killChainSubFormat) ? "" : DSUtil.Format(s.killChainSubFormat, kills);
mainLabel.text = main;
subLabel.text = sub;
LastText = main + (string.IsNullOrEmpty(sub) ? "" : " / " + sub);
var c = s.TierColor(tier);
mainLabel.color = c;
subLabel.color = new Color(c.r, c.g, c.b, c.a * 0.8f);
LastScale = s.TierScale(tier);
_lifeMax = _life = Mathf.Max(0.01f, s.killChainSeconds);
_elapsed = 0f;
group.alpha = 1f;
box.localScale = Vector3.one * (LastScale * WLTextFxUtil.PunchScale(0f, s.killChainPunchScale, s.killChainPunchSeconds));
enabled = true;
return "tier=" + tier + " name=" + tierName + " kills=" + kills + " text=\"" + LastText +
"\" scale=" + box.localScale.x.ToString("F3") + " pos=" + box.anchoredPosition + " alpha=" + group.alpha;
}
public void Hide()
{
if (group != null) group.alpha = 0f;
if (box != null) box.localScale = Vector3.one;
_life = 0f;
}
private void Update()
{
if (_life <= 0f) return;
var s = WLCombatTextSettings.Instance;
if (s == null) { Hide(); return; }
float dt = Time.unscaledDeltaTime; // 히트스톱(TimeScaleArbiter) 중에도 문구는 흐른다
_life -= dt; _elapsed += dt;
float passed = WLTextFxUtil.Passed(_life, _lifeMax);
group.alpha = WLTextFxUtil.FadeAlpha(passed, s.killChainFadeStartRatio);
box.localScale = Vector3.one * (LastScale * WLTextFxUtil.PunchScale(_elapsed, s.killChainPunchScale, s.killChainPunchSeconds));
if (_life <= 0f) Hide();
}
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
/// <summary>합성 티어 이벤트 — 811e 의 실제 Dispatch 경로로 보낸다(구독 배선까지 함께 검증).</summary>
public static string RaiseFakeTier(int tier, string name, int kills)
{
var e = new KillChainTierEvent
{
tier = tier, name = name, kills = kills, centroid = Vector3.zero,
lastVictim = null, burstSpawned = false, time = Time.unscaledTime, frame = Time.frameCount
};
KillChain.TierReached.Dispatch(in e);
return "dispatch tier=" + tier + " name=" + name + " kills=" + kills +
" 구독자=" + KillChain.TierReached.Count;
}
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("KillChainText subscribed=" + _subscribed + " tierSeen=" + TierSeen +
" lastTier=" + LastTier + " lastKills=" + LastKills);
sb.AppendLine(" text=\"" + LastText + "\" alpha=" + (group != null ? group.alpha.ToString("F2") : "-") +
" scale=" + (box != null ? box.localScale.x.ToString("F3") : "-") +
" pos=" + (box != null ? box.anchoredPosition.ToString() : "-") +
" size=" + (box != null ? box.sizeDelta.ToString() : "-"));
sb.AppendLine(" fontSize=" + (mainLabel != null ? mainLabel.fontSize.ToString("F1") : "-") +
" color=" + (mainLabel != null ? ColorUtility.ToHtmlStringRGB(mainLabel.color) : "-"));
return sb.ToString();
}
public void SetFont(TMP_FontAsset f)
{
font = f;
if (mainLabel != null && f != null) mainLabel.font = f;
if (subLabel != null && f != null) subLabel.font = f;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 649e686492f34f444a5a77f815f22b9a

View File

@ -0,0 +1,351 @@
// ─────────────────────────────────────────────────────────────────────────────
// LootToast.cs — 전리품 획득 토스트 (WL-813g §1-3 · #813)
//
// 기준서 v1 §B 요소 4 「토스트 1.2 s · 동시 3줄 · 등급 색(보유 팔레트 그대로)」 · §C 요소 4 「토스트 없음 → 813g(U)」.
//
// ■ 입력 경로 (지금 / 나중)
// · 지금 = `CombatEvents.Killed`(811b 코어) 로 "뭔가 죽었다"를 알고, 그 뒤 `DropItemInfo.m_StageDropData`
// 획득 집계의 **델타**를 폴링해 실제로 먹은 아이템만 줄로 띄운다(원본 훅 0 · Gameplay 코드 0).
// 집계는 `DropItem.cs:125 Add_Item` 이 채우는 값이라 "떨어진 것"이 아니라 "**주운 것**"이다.
// · 나중 = 813f 가 드랍/획득 이벤트를 내면 `Push(itemId, count)` 로 바로 밀어 넣고 폴링을 걷어낸다(후속).
//
// ■ 등급 색 = `MyValue.Get_GradeColor`(원본 팔레트) 를 그대로 쓴다 — 기준서 §B 요소 4 「새 색 만들 이유 없음」.
// 설정 에셋의 lootToastGradeColors 를 채우면 그 값이 우선한다(SO 이관 대비).
//
// ■ Safe Area (813c 규칙) — 부모(`IngameUIs/WL_HUD`)가 이미 SafeAreaFitter 아래다. 여기서는 부모 기준 우측 정렬만 한다.
//
// ■ 롤백(C8) — 설정이 없거나 textEnabled/lootToastEnabled = false 면 구독·폴링 0 · 표시 0.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using WL.Combat.Core;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class LootToast : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private RectTransform lineRoot;
[SerializeField] private TMP_FontAsset font;
private class Line
{
public RectTransform rt;
public TextMeshProUGUI label;
public CanvasGroup group;
public int itemId, count;
public float life, lifeMax, elapsed;
public bool used;
}
private readonly List<Line> _lines = new List<Line>();
private readonly Dictionary<int, int> _seen = new Dictionary<int, int>(); // itemId → 마지막으로 본 누적 수량
private RectTransform _rt;
private bool _subscribed;
private float _nextPoll;
private bool _armed; // Killed 를 한 번이라도 본 뒤부터 폴링한다(로비에서 헛돌지 않게)
// ── 진단(프로브가 읽는다 · 실측만)
public int KilledSeen { get; private set; }
public int PushCount { get; private set; }
public int MergeCount { get; private set; }
public int DropCount { get; private set; } // 줄 상한 초과로 밀려난 수
public bool Subscribed { get { return _subscribed; } }
public int VisibleLines
{
get { int n = 0; for (int i = 0; i < _lines.Count; i++) if (_lines[i].used) n++; return n; }
}
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로.</summary>
public string Initialize()
{
_rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
HideAll();
var s = WLCombatTextSettings.Instance;
bool on = WLCombatTextSettings.Enabled && s != null && s.lootToastEnabled;
Subscribe(on);
return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
if (on) CombatEvents.Killed.Add(OnKilled);
else CombatEvents.Killed.Remove(OnKilled);
_subscribed = on;
}
/// <summary>설정의 줄 수만큼 라벨을 만든다(줄 수를 바꾸면 다시 만든다).</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLCombatTextSettings.Instance;
int want = s != null ? Mathf.Max(1, s.lootToastLines) : 3;
bool made = false;
if (lineRoot == null) { lineRoot = WLTextFxUtil.NewChild(_rt, "Lines"); made = true; }
while (_lines.Count < want)
{
int idx = _lines.Count;
var lrt = WLTextFxUtil.NewChild(lineRoot, "Line" + idx);
var label = WLTextFxUtil.NewText(lrt, "Label", font, TextAlignmentOptions.Right);
var cg = lrt.GetComponent<CanvasGroup>();
if (cg == null) cg = lrt.gameObject.AddComponent<CanvasGroup>();
cg.interactable = false; cg.blocksRaycasts = false; cg.alpha = 0f;
_lines.Add(new Line { rt = lrt, label = label, group = cg });
made = true;
}
for (int i = want; i < _lines.Count; i++)
if (_lines[i].rt != null) _lines[i].group.alpha = 0f; // 남는 줄은 숨기기만(파괴하지 않는다)
return made;
}
/// <summary>우측 정렬 · 줄 간격 · 글자 크기 (px → 유닛 환산은 캔버스에서 · 상수 0).</summary>
public string ApplyLayout()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀";
if (_rt == null) _rt = GetComponent<RectTransform>();
if (lineRoot == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = WLTextFxUtil.UnitsPerPx(this, s);
WLTextFxUtil.Stretch(_rt);
lineRoot.anchorMin = lineRoot.anchorMax = new Vector2(1f, 0.5f); // 우측 · 세로 중앙 기준
lineRoot.pivot = new Vector2(1f, 0.5f);
lineRoot.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * s.lootToastLines * u);
lineRoot.anchoredPosition = new Vector2(-s.lootToastRightMarginPx * u, s.lootToastCenterOffsetPx * u);
var sb = new StringBuilder();
sb.Append("lines=").Append(_lines.Count).Append(" root pos=").Append(lineRoot.anchoredPosition)
.Append(" size=").Append(lineRoot.sizeDelta).Append(" unitsPerPx=").Append(u.ToString("F4"));
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
l.rt.anchorMin = l.rt.anchorMax = new Vector2(1f, 1f);
l.rt.pivot = new Vector2(1f, 1f);
l.rt.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * u);
l.rt.anchoredPosition = new Vector2(0f, -s.lootToastLineHeightPx * i * u);
WLTextFxUtil.Stretch(l.label.rectTransform);
l.label.fontSize = s.lootToastFontPx * u;
l.label.alignment = TextAlignmentOptions.MidlineRight;
sb.Append(" · [").Append(i).Append("] pos=").Append(l.rt.anchoredPosition);
}
return sb.ToString();
}
// ───────────────────────────────────────────── 입력
private void OnKilled(in KilledEvent e)
{
KilledSeen++;
_armed = true; // 처치가 있어야 드랍이 있다 — 그 뒤부터만 획득 집계를 본다
}
/// <summary>813f(후속) · 프로브 공용 입력 — 아이템 1건 획득을 줄로 띄운다.</summary>
public string Push(int itemId, int count)
{
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.lootToastEnabled) return "설정 off — 표시 0";
if (!s.lootToastIncludeGold && itemId == GoldItemId) return "골드 제외(설정) — 표시 0";
int grade = 1;
string name = itemId.ToString();
// 표가 아직 안 올라온 시점(에디트 모드·로딩 중)에도 죽지 않게 감싼다 — 이름/등급이 없으면 ID·등급 1 로 뜬다.
try
{
var data = table_itemlist.Ins != null ? table_itemlist.Ins.Get_Data(itemId) : null;
if (data != null)
{
grade = Mathf.Max(1, data.n_ItemGrade);
var n = data.Get_Name();
if (!string.IsNullOrEmpty(n)) name = n;
}
}
catch { /* 표 미로드 · ID 없음 — 기본값으로 표시 */ }
if (grade < s.lootToastMinGrade) return "등급 " + grade + " < 최소 " + s.lootToastMinGrade + " — 표시 0";
BuildIfNeeded();
// 같은 아이템이 병합 창 안이면 기존 줄의 수량만 올린다(줄 폭주 방지)
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
if (!l.used || l.itemId != itemId) continue;
if (l.elapsed > s.lootToastMergeSeconds) continue;
l.count += count;
l.life = l.lifeMax; l.elapsed = 0f;
Render(l, name, grade, s);
MergeCount++;
return "merge item=" + itemId + " count=" + l.count + " line=" + i;
}
int slot = TakeSlot(s);
var line = _lines[slot];
line.used = true; line.itemId = itemId; line.count = count;
line.lifeMax = line.life = Mathf.Max(0.01f, s.lootToastSeconds);
line.elapsed = 0f;
line.group.alpha = 1f;
Render(line, name, grade, s);
PushCount++;
return "push item=" + itemId + " \"" + name + "\" grade=" + grade + " count=" + count + " line=" + slot;
}
/// <summary>빈 줄을 찾는다. 없으면 가장 오래된 줄을 밀어내고(위로 한 칸씩 당김) 마지막 자리를 준다.</summary>
private int TakeSlot(WLCombatTextSettings s)
{
int n = Mathf.Min(_lines.Count, Mathf.Max(1, s.lootToastLines));
for (int i = 0; i < n; i++) if (!_lines[i].used) return i;
// 전부 차 있음 — 0번(가장 오래된 줄)을 버리고 한 칸씩 올린다(내용만 옮기고 RectTransform 은 그대로).
DropCount++;
for (int i = 0; i < n - 1; i++)
{
var a = _lines[i]; var b = _lines[i + 1];
a.itemId = b.itemId; a.count = b.count; a.life = b.life; a.lifeMax = b.lifeMax; a.elapsed = b.elapsed;
a.used = b.used; a.label.text = b.label.text; a.label.color = b.label.color; a.group.alpha = b.group.alpha;
}
_lines[n - 1].used = false;
return n - 1;
}
private void Render(Line l, string name, int grade, WLCombatTextSettings s)
{
string colored = s.GradeColorTag(grade) + name;
l.label.text = l.count > 1
? DSUtil.Format(s.lootToastFormat, colored, l.count)
: DSUtil.Format(s.lootToastFormatSingle, colored);
l.label.color = Color.white; // 등급색은 리치텍스트 태그가 담당한다(원본 팔레트 그대로)
}
public void HideAll()
{
for (int i = 0; i < _lines.Count; i++)
{
_lines[i].used = false;
_lines[i].life = 0f;
if (_lines[i].group != null) _lines[i].group.alpha = 0f;
if (_lines[i].label != null) _lines[i].label.text = "";
}
}
// ───────────────────────────────────────────── 폴링 · 수명
/// <summary>골드 아이템 ID — 원본 DropItemInfo 의 코인 분기 키(`DropItemInfo.cs:12` dic_addrPath[2]).</summary>
public const int GoldItemId = 2;
private void Update()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return;
// ── 획득 집계 폴링(813f 이벤트가 오면 걷어낸다)
if (_armed && Application.isPlaying && s.lootToastPollHz > 0f && Time.unscaledTime >= _nextPoll)
{
_nextPoll = Time.unscaledTime + 1f / Mathf.Max(0.1f, s.lootToastPollHz);
PollStageDrop();
}
// ── 줄 수명
float dt = Time.unscaledDeltaTime;
float u = WLTextFxUtil.UnitsPerPx(this, s);
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
if (!l.used) continue;
l.life -= dt; l.elapsed += dt;
float passed = WLTextFxUtil.Passed(l.life, l.lifeMax);
l.group.alpha = WLTextFxUtil.FadeAlpha(passed, s.lootToastFadeStartRatio);
if (s.lootToastSlideInPx > 0f && s.lootToastSlideSeconds > 0f)
{
float k = Mathf.Clamp01(l.elapsed / s.lootToastSlideSeconds);
float x = Mathf.Lerp(s.lootToastSlideInPx, 0f, k) * u;
l.rt.anchoredPosition = new Vector2(x, -s.lootToastLineHeightPx * i * u);
}
if (l.life <= 0f) { l.used = false; l.group.alpha = 0f; l.label.text = ""; }
}
}
/// <summary>StageDropData 의 아이템 누적 수량 델타 = 이번에 주운 것.</summary>
public string PollStageDrop()
{
if (!DropItemInfo.isIns || DropItemInfo.Ins == null) return "DropItemInfo 없음";
var data = DropItemInfo.Ins.m_StageDropData;
if (data == null) return "StageDropData 없음";
var dic = data.Get_Item();
if (dic == null) return "집계 없음";
var sb = new StringBuilder();
foreach (var kv in dic)
{
int id = kv.Key;
int now = kv.Value;
int last;
if (!_seen.TryGetValue(id, out last)) last = 0;
if (now > last)
{
DropCountedItems++;
sb.Append(Push(id, now - last)).Append(" | ");
}
_seen[id] = now;
}
return sb.Length > 0 ? sb.ToString() : "델타 0";
}
/// <summary>진단 — 폴링으로 잡아낸 획득 건수.</summary>
public int DropCountedItems { get; private set; }
/// <summary>스테이지가 바뀌면 집계 기준을 초기화한다(StageDropData.Init 과 짝).</summary>
public void ResetPollBaseline() { _seen.Clear(); _armed = false; DropCountedItems = 0; }
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
/// <summary>합성 처치 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증).</summary>
public static string RaiseFakeKilled()
{
var e = new KilledEvent
{
victim = null, killer = null, subRole = eSubRol.None, id = 0,
position = Vector3.zero, byDirectHit = false, time = Time.unscaledTime, frame = Time.frameCount
};
CombatEvents.Killed.Dispatch(in e);
return "dispatch Killed · 구독자=" + CombatEvents.Killed.Count;
}
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("LootToast subscribed=" + _subscribed + " killedSeen=" + KilledSeen +
" push=" + PushCount + " merge=" + MergeCount + " drop=" + DropCount +
" visible=" + VisibleLines);
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
sb.AppendLine(" [" + i + "] used=" + l.used + " item=" + l.itemId + " x" + l.count +
" alpha=" + l.group.alpha.ToString("F2") + " pos=" + l.rt.anchoredPosition +
" size=" + l.rt.sizeDelta + " font=" + l.label.fontSize.ToString("F1") +
" text=\"" + l.label.text + "\"");
}
return sb.ToString();
}
public void SetFont(TMP_FontAsset f)
{
font = f;
for (int i = 0; i < _lines.Count; i++)
if (_lines[i].label != null && f != null) _lines[i].label.font = f;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 169f8694dff47c6409dc8af946ea2dcf

View File

@ -0,0 +1,297 @@
// ─────────────────────────────────────────────────────────────────────────────
// StatPopup.cs — 레벨업 스탯 팝업 (WL-813g §4 · #813)
//
// 기준서 v1 §B 요소 5 「레벨업 = FX + HP/MP 전회복 + **스탯 팝업 3개**」 · §C 요소 5 「이벤트 0 → 813k(S)」.
//
// 🔴 **현재 상태 = 인터페이스 + 표시만 (발생원은 후속)**
// · `WL.Combat.Core.CombatEvents.LevelUp` 는 811b 가 **API 만** 만들어 두었고 발생 지점
// (`ServerClass.Add_Exp :2729-2734` · Systems 소유)은 **813k** 몫이다 — 이 세션 시점에
// `wl/gameplay/WL-813k-levelup` 브랜치에는 실작업 커밋이 없다(`git diff main...` 비어 있음 · 실측).
// · 그래서 페이로드는 지금 `{pc, newLevel}` 뿐이다. 이전/새 스탯 3개를 813k 가 실어 보내면
// **`StatPopup.Report(...)` 로 그대로 밀어 넣으면 된다**(아래 공개 API · 이 파일만 수정하면 끝).
// · 그 전까지는 이 컴포넌트가 스스로 **직전 스냅샷과의 차이**로 스탯 증가분을 만든다(대체 경로).
//
// ■ 롤백(C8) — 설정이 없거나 textEnabled/statPopupEnabled = false 면 구독 0 · 표시 0.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using WL.Combat.Core;
namespace WL.UI
{
/// <summary>스탯 한 줄 — 813k 가 이전/새 값을 실어 보낼 때 쓰는 페이로드.</summary>
public struct WLStatDelta
{
public string name;
public double prev;
public double next;
public double Delta { get { return next - prev; } }
}
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class StatPopup : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform box;
[SerializeField] private TextMeshProUGUI titleLabel;
[SerializeField] private TMP_FontAsset font;
private readonly List<TextMeshProUGUI> _rows = new List<TextMeshProUGUI>();
private readonly Dictionary<int, double> _snapshot = new Dictionary<int, double>();
private RectTransform _rt;
private bool _subscribed;
private float _life, _lifeMax, _elapsed;
/// <summary>813k(또는 다른 발생원)가 찾아 쓸 수 있는 현재 활성 팝업.</summary>
public static StatPopup Active { get; private set; }
// ── 진단(프로브가 읽는다 · 실측만)
public int LevelUpSeen { get; private set; }
public int LastLevel { get; private set; }
public int LastRowCount { get; private set; }
public string LastText { get; private set; }
public bool Subscribed { get { return _subscribed; } }
public bool Visible { get { return group != null && group.alpha > 0f; } }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); if (Active == this) Active = null; }
/// <summary>OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로.</summary>
public string Initialize()
{
_rt = GetComponent<RectTransform>();
Active = this;
BuildIfNeeded();
string layout = ApplyLayout();
Hide();
var s = WLCombatTextSettings.Instance;
bool on = WLCombatTextSettings.Enabled && s != null && s.statPopupEnabled;
Subscribe(on);
TakeSnapshot();
return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
if (on) CombatEvents.LevelUp.Add(OnLevelUp);
else CombatEvents.LevelUp.Remove(OnLevelUp);
_subscribed = on;
}
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLCombatTextSettings.Instance;
int want = s != null ? Mathf.Max(1, s.statPopupRows) : 3;
bool made = false;
if (group == null)
{
group = GetComponent<CanvasGroup>();
if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; }
group.interactable = false; group.blocksRaycasts = false;
}
if (box == null) { box = WLTextFxUtil.NewChild(_rt, "Box"); made = true; }
if (titleLabel == null) { titleLabel = WLTextFxUtil.NewText(box, "Title", font, TextAlignmentOptions.Center); made = true; }
while (_rows.Count < want)
{
_rows.Add(WLTextFxUtil.NewText(box, "Row" + _rows.Count, font, TextAlignmentOptions.Center));
made = true;
}
return made;
}
public string ApplyLayout()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀";
if (_rt == null) _rt = GetComponent<RectTransform>();
if (box == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = WLTextFxUtil.UnitsPerPx(this, s);
WLTextFxUtil.Stretch(_rt);
float h = (s.statPopupTitleFontPx + s.statPopupRowHeightPx * s.statPopupRows) * u;
box.anchorMin = box.anchorMax = new Vector2(0.5f, 0.5f);
box.pivot = new Vector2(0.5f, 0.5f);
box.sizeDelta = new Vector2(s.designWidthPx * u, h);
box.anchoredPosition = new Vector2(0f, s.statPopupCenterOffsetPx * u);
titleLabel.rectTransform.anchorMin = titleLabel.rectTransform.anchorMax = new Vector2(0.5f, 1f);
titleLabel.rectTransform.pivot = new Vector2(0.5f, 1f);
titleLabel.rectTransform.sizeDelta = new Vector2(s.designWidthPx * u, s.statPopupTitleFontPx * 1.2f * u);
titleLabel.rectTransform.anchoredPosition = Vector2.zero;
titleLabel.fontSize = s.statPopupTitleFontPx * u;
titleLabel.color = s.statPopupTitleColor;
for (int i = 0; i < _rows.Count; i++)
{
var rt = _rows[i].rectTransform;
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.sizeDelta = new Vector2(s.designWidthPx * u, s.statPopupRowHeightPx * u);
rt.anchoredPosition = new Vector2(0f, -(s.statPopupTitleFontPx * 1.2f + s.statPopupRowHeightPx * i) * u);
_rows[i].fontSize = s.statPopupRowFontPx * u;
_rows[i].color = s.statPopupRowColor;
}
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count +
" unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1");
}
// ───────────────────────────────────────────── 이벤트 · 공개 API
private void OnLevelUp(in LevelUpEvent e)
{
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.statPopupEnabled) return;
Report(e.newLevel, BuildDeltasFromSnapshot(e.pc, s));
}
/// <summary>
/// 🔵 813k 연결점 — 이전/새 스탯을 그대로 실어 보내면 이 메서드가 팝업을 띄운다.
/// deltas 가 null 이면 컴포넌트가 스스로 스냅샷 차이를 만든다.
/// </summary>
public static string Report(int newLevel, WLStatDelta[] deltas)
{
if (Active == null) return "StatPopup 없음(프리팹 노드 미배치)";
return Active.Show(newLevel, deltas);
}
/// <summary>팝업을 띄운다(프로브도 이 경로를 쓴다).</summary>
public string Show(int newLevel, WLStatDelta[] deltas)
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "설정 없음";
BuildIfNeeded();
ApplyLayout();
LevelUpSeen++; LastLevel = newLevel;
titleLabel.text = DSUtil.Format(s.statPopupTitleFormat, newLevel);
int shown = 0;
var sb = new StringBuilder(titleLabel.text);
for (int i = 0; i < _rows.Count; i++)
{
bool has = deltas != null && i < deltas.Length;
double d = has ? deltas[i].Delta : 0d;
if (!has || (!s.statPopupShowZero && d <= 0d)) { _rows[i].text = ""; continue; }
_rows[i].text = DSUtil.Format(s.statPopupRowFormat, deltas[i].name, d.ToString("N0"), deltas[i].next.ToString("N0"));
sb.Append(" / ").Append(_rows[i].text);
shown++;
}
LastRowCount = shown;
LastText = sb.ToString();
_lifeMax = _life = Mathf.Max(0.01f, s.statPopupSeconds);
_elapsed = 0f;
group.alpha = 1f;
box.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.statPopupPunchScale, s.statPopupPunchSeconds);
TakeSnapshot();
return "level=" + newLevel + " rows=" + shown + " text=\"" + LastText + "\" pos=" + box.anchoredPosition;
}
public void Hide()
{
if (group != null) group.alpha = 0f;
if (box != null) box.localScale = Vector3.one;
_life = 0f;
}
private void Update()
{
if (_life <= 0f) return;
var s = WLCombatTextSettings.Instance;
if (s == null) { Hide(); return; }
float dt = Time.unscaledDeltaTime;
_life -= dt; _elapsed += dt;
float passed = WLTextFxUtil.Passed(_life, _lifeMax);
group.alpha = WLTextFxUtil.FadeAlpha(passed, s.statPopupFadeStartRatio);
box.localScale = Vector3.one * WLTextFxUtil.PunchScale(_elapsed, s.statPopupPunchScale, s.statPopupPunchSeconds);
if (_life <= 0f) Hide();
}
// ───────────────────────────────────────────── 스냅샷 대체 경로(813k 전까지)
/// <summary>설정에 적힌 스탯들의 현재 값을 기억한다.</summary>
public void TakeSnapshot()
{
var s = WLCombatTextSettings.Instance;
if (s == null || s.statPopupStats == null) return;
var stat = (Application.isPlaying && MyValue.bMyPC && MyValue.MyPC != null) ? MyValue.MyPC.Get_StatInfo() : null;
if (stat == null) return;
for (int i = 0; i < s.statPopupStats.Length; i++)
_snapshot[(int)s.statPopupStats[i]] = stat.Get_Stat(s.statPopupStats[i]);
}
private WLStatDelta[] BuildDeltasFromSnapshot(Actor pc, WLCombatTextSettings s)
{
if (s.statPopupStats == null || s.statPopupStats.Length == 0) return null;
var actor = pc != null ? pc : (MyValue.bMyPC ? (Actor)MyValue.MyPC : null);
if (actor == null) return null;
var stat = actor.Get_StatInfo();
if (stat == null) return null;
int n = Mathf.Min(s.statPopupStats.Length, Mathf.Max(1, s.statPopupRows));
var arr = new WLStatDelta[n];
for (int i = 0; i < n; i++)
{
var key = s.statPopupStats[i];
double now = stat.Get_Stat(key);
double prev;
if (!_snapshot.TryGetValue((int)key, out prev)) prev = now;
arr[i] = new WLStatDelta { name = key.ToString(), prev = prev, next = now };
}
return arr;
}
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
/// <summary>합성 레벨업 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증).</summary>
public static string RaiseFakeLevelUp(int newLevel)
{
var e = new LevelUpEvent { pc = null, newLevel = newLevel, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.LevelUp.Dispatch(in e);
return "dispatch LevelUp level=" + newLevel + " · 구독자=" + CombatEvents.LevelUp.Count;
}
/// <summary>합성 스탯 델타로 직접 표시(813k 페이로드가 오면 이 모양이 된다).</summary>
public string ShowFake(int newLevel, string[] names, double[] prev, double[] next)
{
int n = Mathf.Min(names.Length, Mathf.Min(prev.Length, next.Length));
var arr = new WLStatDelta[n];
for (int i = 0; i < n; i++) arr[i] = new WLStatDelta { name = names[i], prev = prev[i], next = next[i] };
return Show(newLevel, arr);
}
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("StatPopup subscribed=" + _subscribed + " levelUpSeen=" + LevelUpSeen +
" lastLevel=" + LastLevel + " rows=" + LastRowCount +
" alpha=" + (group != null ? group.alpha.ToString("F2") : "-"));
sb.AppendLine(" title=\"" + (titleLabel != null ? titleLabel.text : "") + "\" pos=" +
(box != null ? box.anchoredPosition.ToString() : "-") +
" size=" + (box != null ? box.sizeDelta.ToString() : "-") +
" scale=" + (box != null ? box.localScale.x.ToString("F3") : "-"));
for (int i = 0; i < _rows.Count; i++)
sb.AppendLine(" row[" + i + "] \"" + _rows[i].text + "\" pos=" + _rows[i].rectTransform.anchoredPosition +
" font=" + _rows[i].fontSize.ToString("F1"));
return sb.ToString();
}
public void SetFont(TMP_FontAsset f)
{
font = f;
if (titleLabel != null && f != null) titleLabel.font = f;
for (int i = 0; i < _rows.Count; i++) if (_rows[i] != null && f != null) _rows[i].font = f;
}
}
}

View File

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

View File

@ -0,0 +1,291 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSkillSlotOverride.cs — 전투 패드 4슬롯 장착 **로컬 오버라이드** + MP 부족 흐림 (WL-813g §3 · #813)
//
// 813c 완료보고 §5 이월(「슬롯 장착은 서버 프리셋이라 813c 와 묶어야 함」 · 813n §1) · 기준서 v1 §B 요소 3.
//
// ■ 무엇을 하나 (UI 코드 안 · 원본 훅 0 · 핫스팟(PCActor/Actor) 변경 0)
// ① WLSkillSlotPreset.asset 에서 **내 클래스 행**(813n 4클래스 × 4슬롯)을 찾아,
// `ServerData.Equip.Set_Equip(eItem.Skill, id, slot)` — 클라 메모리 전용 공개 API(서버 호출 0) — 로 써 넣는다.
// 그 뒤 `PCActor.Set/ReSet` 이 `Get_EquipSkillID(i)` 로 읽는 **기존 `Use_Skill` 경로 그대로** 발동한다.
// ② 이미 카드가 서버 값으로 채워진 뒤라면 `SkillCard.Set(데이터)` 로 아이콘만 즉시 맞춘다(표시 동기화).
// ③ MP 가 모자란 슬롯의 버튼을 흐리게 한다(`Actor.cs:733-741` 과 **같은 식**: n_MP n_MP·SKILL_COST > MP).
//
// 🔴 실행 순서 주의(실측 · Play 없이 정적 확인)
// `PCActor.Set()`(:151-163)은 `dic_MagicData` 를 **한 번만** 만든다(`ReSet()` 은 `arr_magicData` 만 새로 읽는다).
// `Actor.Use_Skill(:707)` 은 `m_Magic = dic_MagicData[index]` 를 쓰므로, 오버라이드가 `PCActor.Set()` **뒤**에
// 적용되면 아이콘·게이트·쿨·애니는 새 스킬이지만 발사 실체(`m_SkillData.m_SkillListTableData`)는 옛 스킬로 남는다.
// → 이 컴포넌트는 `ServerData` 가 준비되는 즉시(패드 활성 이전부터) 적용을 시도하고,
// 늦게 적용됐으면 `LateApplied = true` 를 남긴다(완료보고 「미확인」 항목 · Play 검증 필요).
//
// ■ 롤백(C8)
// WLSkillSlotPreset.asset 이 없거나 overrideEnabled=false 면 **아무 것도 쓰지 않는다** = 서버 프리셋 원본.
// restoreOnDisable 이면 꺼질 때 원래 SkillID 를 그대로 복원한다.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
namespace WL.UI
{
[DisallowMultipleComponent]
public class WLSkillSlotOverride : MonoBehaviour
{
[Tooltip("대상 전투 패드 — 비우면 같은 오브젝트의 BattleUI 를 찾는다")]
[SerializeField] private BattleUI target;
// ── 진단(프로브가 읽는다 · 실측만 채운다)
public static bool Applied;
public static bool LateApplied;
public static int AppliedClassId;
public static int AppliedSlots;
public static string LastInfo = "";
public static int[] LastBefore = new int[0];
public static int[] LastAfter = new int[0];
private readonly List<int> _restore = new List<int>();
private bool _didApply;
private float _nextMpCheck;
private float[] _alpha;
private SkillListTableData[] _slotData;
/// <summary>Play 중에만 서버 데이터를 만진다 — ServerInfo.Ins 는 없으면 GameObject 를 만든다(에디트 모드 오염 금지).</summary>
private static ServerData LiveServerData
{
get { return Application.isPlaying && ServerInfo.Ins != null ? ServerInfo.Ins.m_ServerData : null; }
}
private void OnEnable() { Initialize(); }
private void OnDisable() { RestoreIfNeeded(); }
/// <summary>에디트 모드에서도 런타임과 같은 경로를 타게 하기 위해 OnEnable 본체를 분리한다(813c 교훈).</summary>
public string Initialize()
{
if (target == null) target = GetComponent<BattleUI>();
_nextMpCheck = 0f;
return TryApply();
}
private void Update()
{
if (!_didApply) TryApply(); // ServerData 가 늦게 준비되는 경우 재시도(가벼움 · 실패는 즉시 반환)
UpdateMpGate();
}
// ───────────────────────────────────────────── ① 슬롯 오버라이드
/// <summary>설정대로 내 클래스의 4슬롯을 써 넣는다. 설정·서버데이터가 없으면 아무 것도 하지 않는다.</summary>
public string TryApply()
{
if (!WLSkillSlotPreset.Enabled) return "WLSkillSlotPreset 없음/off — 서버 프리셋 유지";
var sdata = LiveServerData;
if (sdata == null || sdata.Equip == null || sdata.Equip.Skill == null) return "ServerData 미준비 — 재시도";
int classId = ResolveClassId(sdata);
if (classId == 0) return "클래스 ID 미확인 — 재시도";
var preset = WLSkillSlotPreset.Instance;
int n = preset.SlotCountFor(classId);
if (n <= 0) { _didApply = true; LastInfo = "클래스 " + classId + " 행 없음 — 오버라이드 0"; return LastInfo; }
string log = ApplyTo(sdata.Equip, preset, classId, _restore);
_didApply = true;
Applied = true;
AppliedClassId = classId;
AppliedSlots = n;
LateApplied = MyValue.bMyPC; // PC 가 이미 살아 있으면 dic_MagicData 는 옛 값일 수 있다(위 🔴)
LastInfo = log;
if (preset.verboseLog) Debug.Log("[WLSkillSlotOverride] " + log);
RefreshCards(sdata, preset, classId, n);
return log;
}
/// <summary>
/// SD_Equip 에 프리셋을 써 넣는다(테스트에서 합성 SD_Equip 으로도 부를 수 있게 정적 · 부작용은 인자만).
/// restore 에 원래 값을 남긴다(복원용).
/// </summary>
public static string ApplyTo(SD_Equip equip, WLSkillSlotPreset preset, int classId, List<int> restore)
{
var sb = new StringBuilder();
int p = equip.Preset;
if (!equip.Skill.ContainsKey(p)) return "Equip.Skill[" + p + "] 없음 — 오버라이드 0";
var lst = equip.Skill[p];
int n = Mathf.Min(preset.SlotCountFor(classId), lst.Count);
var before = new int[n];
var after = new int[n];
if (restore != null && restore.Count == 0)
for (int i = 0; i < lst.Count; i++) restore.Add(lst[i]);
for (int i = 0; i < n; i++) before[i] = lst[i];
for (int i = 0; i < n; i++)
{
int id = preset.SkillIdFor(classId, i);
if (id <= 0) continue; // 0 = 그 슬롯은 서버 값 유지
equip.Set_Equip(eItem.Skill, id, i);
}
for (int i = 0; i < n; i++) after[i] = lst[i];
LastBefore = before; LastAfter = after;
sb.Append("class=").Append(classId).Append(" preset=").Append(p).Append(" slots=").Append(n).Append(" · ");
for (int i = 0; i < n; i++)
{
if (i > 0) sb.Append(' ');
sb.Append('[').Append(i).Append("] ").Append(before[i]).Append("→").Append(after[i]);
}
return sb.ToString();
}
private void RestoreIfNeeded()
{
var preset = WLSkillSlotPreset.Instance;
if (preset == null || !preset.restoreOnDisable || _restore.Count == 0) { _restore.Clear(); _didApply = false; return; }
var sdata = LiveServerData;
if (sdata != null && sdata.Equip != null && sdata.Equip.Skill != null)
{
int p = sdata.Equip.Preset;
if (sdata.Equip.Skill.ContainsKey(p))
{
var lst = sdata.Equip.Skill[p];
for (int i = 0; i < lst.Count && i < _restore.Count; i++)
{
var v = lst[i];
v = _restore[i];
v.RandomizeCryptoKey();
lst[i] = v;
}
}
}
_restore.Clear();
_didApply = false;
Applied = false;
}
/// <summary>내 클래스 ID — 살아 있는 PC 가 있으면 그것, 없으면 장착 PC(Equip[eItem.PC]).</summary>
public static int ResolveClassId(ServerData sdata)
{
if (MyValue.bMyPC && MyValue.MyPC != null)
{
int id = MyValue.MyPC.Get_ID();
if (id > 0) return id;
}
if (sdata != null && sdata.Equip != null && sdata.Equip.Equip != null && sdata.Equip.Equip.ContainsKey(eItem.PC))
return sdata.Equip.Equip[eItem.PC];
return 0;
}
/// <summary>카드가 이미 서버 값으로 채워졌다면 아이콘을 새 스킬로 즉시 맞춘다(표시 동기화 · Use 타입 시각 복원).</summary>
private void RefreshCards(ServerData sdata, WLSkillSlotPreset preset, int classId, int n)
{
if (target == null || target.skillCards == null) return;
if (_slotData == null || _slotData.Length != target.skillCards.Length)
_slotData = new SkillListTableData[target.skillCards.Length];
for (int i = 0; i < n && i < target.skillCards.Length; i++)
{
int id = preset.SkillIdFor(classId, i);
if (id <= 0) continue;
var card = target.skillCards[i];
if (card == null) continue;
var data = table_skilllist.Ins.Get_Data_orNull(id);
if (data == null) continue;
_slotData[i] = data;
card.Set(data);
// SkillCard.Set<T> 는 UI 카드 기준으로 상태 라벨을 켠다 — 전투(Use) 카드에서는 다시 끈다(Set_CardType(Use) 와 같은 상태).
if (card.gos != null)
{
if (card.gos.Length > 0 && card.gos[0] != null) card.gos[0].SetActive(false);
if (card.gos.Length > 2 && card.gos[2] != null) card.gos[2].SetActive(false);
}
}
}
// ───────────────────────────────────────────── ③ MP 부족 흐림
private void UpdateMpGate()
{
var preset = WLSkillSlotPreset.Instance;
if (preset == null || !preset.mpDimEnabled || !WLSkillSlotPreset.Enabled) return;
if (target == null || target.skillCards == null) return;
if (Time.unscaledTime < _nextMpCheck) return;
_nextMpCheck = Time.unscaledTime + Mathf.Max(0.02f, preset.mpCheckInterval);
ApplyMpGate(preset);
}
/// <summary>현재 MP 로 각 슬롯 버튼 알파를 갱신한다(에디트 모드 프로브도 이 경로를 쓴다).</summary>
public string ApplyMpGate(WLSkillSlotPreset preset)
{
if (target == null) target = GetComponent<BattleUI>();
if (target == null || target.skillCards == null) return "BattleUI 없음";
if (!MyValue.bMyPC || MyValue.MyPC == null) return "PC 없음 — MP 게이트 건너뜀";
var stat = MyValue.MyPC.Get_StatInfo();
double mp = stat.Get_Stat(eStat.MP);
double costRate = stat.Get_Stat(eStat.SKILL_COST);
var sb = new StringBuilder();
int cards = target.skillCards.Length;
if (_alpha == null || _alpha.Length != cards) { _alpha = new float[cards]; for (int i = 0; i < cards; i++) _alpha[i] = preset.mpFullAlpha; }
int n = Mathf.Min(preset.slotCount, cards);
for (int i = 0; i < n; i++)
{
var card = target.skillCards[i];
if (card == null || !card.gameObject.activeSelf) continue;
// 오버라이드로 확정된 슬롯 데이터를 먼저 쓰고, 없으면 카드가 들고 있는 데이터(서버 프리셋)로 떨어진다.
var data = (_slotData != null && i < _slotData.Length && _slotData[i] != null)
? _slotData[i] : card.Get_Data() as SkillListTableData;
bool enough = true;
double need = 0d;
if (data != null)
{
need = data.n_MP - (data.n_MP * costRate); // Actor.cs:733 과 같은 식
enough = need <= mp;
}
float want = enough ? preset.mpFullAlpha : preset.mpDimAlpha;
float cur = _alpha[i];
float t = preset.mpDimLerpSeconds <= 0f ? 1f : Mathf.Clamp01(Time.unscaledDeltaTime / preset.mpDimLerpSeconds);
_alpha[i] = Mathf.Lerp(cur, want, t);
SetCardAlpha(card, _alpha[i]);
sb.Append('[').Append(i).Append("] need=").Append(need.ToString("F0"))
.Append(" mp=").Append(mp.ToString("F0")).Append(" enough=").Append(enough)
.Append(" alpha=").Append(_alpha[i].ToString("F2")).Append(' ');
}
return sb.ToString();
}
private static void SetCardAlpha(SkillCard card, float a)
{
var cg = card.GetComponent<CanvasGroup>();
if (cg == null) cg = card.gameObject.AddComponent<CanvasGroup>();
cg.alpha = a;
// 흐릴 때도 누를 수는 있게 둔다 — 실제 차단은 Actor.Use_Skill 의 MP 게이트(ToastUI 106)가 한다.
}
// ───────────────────────────────────────────── 프로브(에디트 모드 · 런타임 의존 0)
/// <summary>클래스 × 슬롯 → SkillID 표를 문자열로 만든다(설정 에셋만 읽는다).</summary>
public static string ResolveDump(WLSkillSlotPreset preset)
{
var sb = new StringBuilder();
if (preset == null) return "WLSkillSlotPreset 없음";
sb.AppendLine("overrideEnabled=" + preset.overrideEnabled + " slotCount=" + preset.slotCount +
" restoreOnDisable=" + preset.restoreOnDisable + " mpDim=" + preset.mpDimEnabled +
" dimAlpha=" + preset.mpDimAlpha.ToString("F2"));
if (preset.rows == null) return sb.Append("rows 없음").ToString();
for (int r = 0; r < preset.rows.Length; r++)
{
var row = preset.rows[r];
sb.Append("class ").Append(row.classId).Append(" :");
int n = preset.SlotCountFor(row.classId);
for (int s = 0; s < n; s++) sb.Append(" [").Append(s).Append("]=").Append(preset.SkillIdFor(row.classId, s));
sb.Append(" // ").AppendLine(row.note);
}
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 376e0db7fbb566748ae0ca78bc4c4f35

View File

@ -0,0 +1,86 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLTextFxUtil.cs — 전투 텍스트 연출 3종이 공유하는 소소한 UI 조립 헬퍼 (WL-813g · #813)
//
// KillChainText · LootToast · StatPopup 이 자기 노드를 스스로 만든다(BuildIfNeeded 패턴 · 813c BossHpBar 선례).
// 프리팹에는 **빈 노드 1개 + 컴포넌트**만 들어가고 자식·좌표는 런타임에 만든다 →
// NewGameUI.prefab diff 를 "노드 추가"로만 유지한다(발주서 §2 ⓒ).
//
// 값은 하나도 들고 있지 않다(전부 WLCombatTextSettings). 여기 있는 숫자는 Unity 기본값(앵커 0/1)뿐이다.
// ─────────────────────────────────────────────────────────────────────────────
using TMPro;
using UnityEngine;
namespace WL.UI
{
internal static class WLTextFxUtil
{
internal const int UILayer = 5; // 813c 와 같은 UI 레이어 인덱스
/// <summary>이름으로 자식을 찾고 없으면 RectTransform 자식을 만든다.</summary>
internal static RectTransform NewChild(RectTransform parent, string name)
{
var t = parent.Find(name) as RectTransform;
if (t != null) return t;
var go = new GameObject(name, typeof(RectTransform));
go.layer = UILayer;
t = (RectTransform)go.transform;
t.SetParent(parent, false);
t.localScale = Vector3.one;
t.localRotation = Quaternion.identity;
return t;
}
/// <summary>부모를 가득 채우도록 늘린다.</summary>
internal static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.pivot = new Vector2(0.5f, 0.5f);
}
/// <summary>자식 TextMeshProUGUI 를 만들거나 찾는다(레이캐스트 off · 폰트는 있으면 주입).</summary>
internal static TextMeshProUGUI NewText(RectTransform parent, string name, TMP_FontAsset font, TextAlignmentOptions align)
{
var t = NewChild(parent, name);
var label = t.GetComponent<TextMeshProUGUI>();
if (label == null) label = t.gameObject.AddComponent<TextMeshProUGUI>();
label.raycastTarget = false;
label.alignment = align;
// 줄바꿈·오버플로 API 는 TMP 버전마다 이름이 갈려(enableWordWrapping ↔ textWrappingMode) 건드리지 않는다 — 기본값 유지.
if (font != null) label.font = font;
return label;
}
/// <summary>px → 캔버스 유닛 계수(802b 규약 · 캔버스 폭 / 기준 폭). 캔버스가 없으면 1.</summary>
internal static float UnitsPerPx(Component c, WLCombatTextSettings s)
{
if (s == null) return 1f;
var canvas = c != null ? c.GetComponentInParent<Canvas>() : null;
return s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null);
}
/// <summary>0 → 1 로 지난 비율(life 는 남은 시간).</summary>
internal static float Passed(float life, float lifeMax)
{
return lifeMax > 0f ? 1f - Mathf.Clamp01(life / lifeMax) : 1f;
}
/// <summary>fadeStart 비율 이후에만 1 → 0 으로 떨어지는 알파.</summary>
internal static float FadeAlpha(float passed, float fadeStart)
{
fadeStart = Mathf.Clamp01(fadeStart);
if (passed < fadeStart) return 1f;
if (fadeStart >= 1f) return 0f;
return 1f - Mathf.Clamp01((passed - fadeStart) / (1f - fadeStart));
}
/// <summary>등장 펀치 스케일(punchSeconds 안에 punchScale → 1 로 수렴).</summary>
internal static float PunchScale(float elapsed, float punchScale, float punchSeconds)
{
if (punchSeconds <= 0f || punchScale <= 1f) return 1f;
float k = Mathf.Clamp01(elapsed / punchSeconds);
return Mathf.Lerp(punchScale, 1f, k * k);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 823b74862a7140d4996dc84ac1032690

View File

@ -0,0 +1,87 @@
%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: 58612e2aec3ae5149af9f8fc191e6177, type: 3}
m_Name: WLCombatTextSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLCombatTextSettings
textEnabled: 1
verboseLog: 0
designWidthPx: 1080
designHeightPx: 1920
damageRhythmEnabled: 1
damageNormalSeconds: 0.6
damageCritSeconds: 1
damageCritScale: 1.6
damageNormalScale: 1
damageMaxConcurrent: 12
damageEvictNormalFirst: 1
damageRiseSpeed: 1
damageCritRiseMultiplier: 1.35
damageFadeStartRatio: 0.55
damageScatterPx: 25
damageCritScatterMultiplier: 0.4
damageUseMergeWindow: 1
damageMergeFormat: '{0}'
damageMergeHitsMin: 2
damageMergeHitsSuffix: ' <size=60%>x{0}</size>'
killChainTextEnabled: 1
killChainSeconds: 0.8
killChainPunchScale: 1.45
killChainPunchSeconds: 0.18
killChainFadeStartRatio: 0.6
killChainTopOffsetPx: 520
killChainFontPx: 96
killChainTierColors:
- {r: 1, g: 0.94, b: 0.72, a: 1}
- {r: 1, g: 0.8, b: 0.35, a: 1}
- {r: 1, g: 0.52, b: 0.22, a: 1}
- {r: 1, g: 0.27, b: 0.28, a: 1}
killChainTierScales:
- 1
- 1.12
- 1.28
- 1.45
killChainFormat: '{0}!'
killChainSubFormat: <size=45%>{0} KILLS</size>
lootToastEnabled: 1
lootToastSeconds: 1.2
lootToastLines: 3
lootToastMergeSeconds: 1
lootToastRightMarginPx: 40
lootToastCenterOffsetPx: 120
lootToastLineHeightPx: 54
lootToastFontPx: 40
lootToastWidthPx: 520
lootToastFadeStartRatio: 0.65
lootToastSlideInPx: 60
lootToastSlideSeconds: 0.14
lootToastFormat: '{0}[-] x{1}'
lootToastFormatSingle: '{0}[-]'
lootToastMinGrade: 1
lootToastIncludeGold: 0
lootToastPollHz: 10
lootToastGradeColors: []
statPopupEnabled: 1
statPopupSeconds: 2
statPopupPunchScale: 1.3
statPopupPunchSeconds: 0.22
statPopupFadeStartRatio: 0.7
statPopupCenterOffsetPx: 180
statPopupTitleFontPx: 72
statPopupRowFontPx: 46
statPopupRowHeightPx: 56
statPopupRows: 3
statPopupStats: 000000000200000004000000
statPopupShowZero: 0
statPopupTitleFormat: LEVEL {0}
statPopupRowFormat: '{0} +{1}'
statPopupTitleColor: {r: 1, g: 0.9, b: 0.45, a: 1}
statPopupRowColor: {r: 0.55, g: 0.95, b: 0.62, a: 1}

View File

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

View File

@ -0,0 +1,37 @@
%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: 7a213dd34e76ecd43963c424aef9e4e6, type: 3}
m_Name: WLSkillSlotPreset
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLSkillSlotPreset
overrideEnabled: 1
verboseLog: 0
restoreOnDisable: 1
slotCount: 4
rows:
- classId: 10101
note: "onehand \xB7 703101/703102/703103 + \uAD11\uC5ED 702006 \uB3CC\uAC1C\uBC14\uB78C"
skillIds: 7dba0a007eba0a007fba0a0036b60a00
- classId: 10102
note: "spellblade \xB7 703106/703105/703104 + \uAD11\uC5ED 702009 \uB9C8\uB098\uD3ED\uBC1C"
skillIds: 82ba0a0081ba0a0080ba0a0039b60a00
- classId: 10501
note: "bluntshield \xB7 703502/703503/703501 + \uAD11\uC5ED 702005 \uD30C\uB3C4"
skillIds: 0ebc0a000fbc0a000dbc0a0035b60a00
- classId: 10502
note: "paladin \xB7 703505/703506/703504 + \uAD11\uC5ED 702012 \uC2EC\uD310\uC758\uBE5B"
skillIds: 11bc0a0012bc0a0010bc0a003cb60a00
mpDimEnabled: 1
mpDimAlpha: 0.4
mpFullAlpha: 1
mpDimLerpSeconds: 0.12
mpCheckInterval: 0.1
mpDimDuringCooldown: 1

View File

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

View File

@ -0,0 +1,336 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLCombatTextSettings.cs — 전투 텍스트 연출 값의 단일 출처 (WL-813g · #813)
//
// 기준서 v1 §B 요소 2 「데미지 숫자 = 일반 0.6 s · 크리 1.0 s · 1.6배 · 합산 윈도우 0.25 s」
// 요소 2 「연쇄 처치 3 s 윈도우 2/3/5/8킬」(윈도우·티어 자체는 811e = WLReactionSettings)
// 요소 4 「토스트 1.2 s · 동시 3줄 · 등급 색」
// 요소 5 「레벨업 = 스탯 팝업 3개」
//
// ■ 왜 이 에셋인가 (C45 · 발주서 §2 ⓓ)
// 지속 시간·배율·상한·오프셋을 코드 상수로 두지 않는다. 이 파일에는 "필드와 기본값"만 있고,
// 게임이 실제로 읽는 값은 Resources/WL/WLCombatTextSettings.asset 이다.
//
// ■ 롤백(C8)
// 에셋이 없거나 enabled = false 면 네 연출(데미지 리듬 · 연쇄 문구 · 전리품 토스트 · 스탯 팝업)이
// 전부 무동작으로 떨어지고 HUDDMGUI 는 **원본 값(0.9 s · 스케일 1배 · 상한 없음)** 으로 돈다.
//
// ■ 좌표계 (px)
// px 값은 세로 1080×1920 기준 화면 픽셀이다(813c WLHudLayoutSettings 와 같은 규약).
// px → 캔버스 유닛 환산은 런타임에 캔버스 폭에서 구한다(802b 노트 · 세로에서 1 px = 1.7778 유닛).
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.UI;
namespace WL.UI
{
[CreateAssetMenu(fileName = "WLCombatTextSettings", menuName = "WL/Combat Text Settings", order = 31)]
public sealed class WLCombatTextSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLCombatTextSettings";
private static WLCombatTextSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 원본 동작 유지 = C8 롤백).</summary>
public static WLCombatTextSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLCombatTextSettings>(ResourcesPath);
}
return s_instance;
}
}
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>진단·A/B 전용 런타임 스위치(에셋을 건드리지 않고 끈다).</summary>
public static bool RuntimeDisabled;
/// <summary>이 웨이브의 텍스트 연출이 살아 있는가 = 에셋 존재 + enabled + 런타임 스위치.</summary>
public static bool Enabled { get { var i = Instance; return i != null && i.textEnabled && !RuntimeDisabled; } }
// ── 전체 스위치 ────────────────────────────────────────────────────────
[Header("전체 스위치")]
[Tooltip("끄면 데미지 리듬 · 연쇄 문구 · 전리품 토스트 · 스탯 팝업이 전부 무동작(원본 HUDDMGUI 동작으로 복귀). 롤백 1순위.")]
public bool textEnabled = true;
[Tooltip("표시 판정을 Console 에 남긴다(검증용 · GC 발생).")]
public bool verboseLog = false;
[Header("기준 해상도 (px · 813c 와 동일 규약)")]
[Tooltip("모든 px 값의 기준이 되는 화면 폭")]
public float designWidthPx = 1080f;
[Tooltip("모든 px 값의 기준이 되는 화면 높이")]
public float designHeightPx = 1920f;
// ── ① 데미지 숫자 리듬 (기준서 §B 요소 2) ──────────────────────────────
[Header("① 데미지 숫자 — 리듬 (HUDDMGUI · 원본 0.9 s · ±25 px 랜덤)")]
[Tooltip("리듬화 사용 여부. off 면 HUDDMGUI 가 원본 상수(0.9 s · 배율 1)로 돈다.")]
public bool damageRhythmEnabled = true;
[Tooltip("일반 타격 숫자 표시 시간(초). 기준서 §B = 0.6 s.")]
public float damageNormalSeconds = 0.6f;
[Tooltip("크리티컬 숫자 표시 시간(초). 기준서 §B = 1.0 s.")]
public float damageCritSeconds = 1.0f;
[Tooltip("크리티컬 글자 크기 배율. 기준서 §B = 1.6배(일반 = 1.0).")]
public float damageCritScale = 1.6f;
[Tooltip("일반 타격 글자 크기 배율(기준 1.0 · 숫자죽 방지로 낮출 수 있다).")]
public float damageNormalScale = 1.0f;
[Tooltip("화면에 동시에 떠 있을 수 있는 데미지 숫자 상한. 초과하면 가장 오래된 것부터 즉시 회수한다. 0 이하 = 상한 없음.")]
public int damageMaxConcurrent = 12;
[Tooltip("상한 초과로 회수할 때 크리티컬은 남기고 일반부터 회수한다.")]
public bool damageEvictNormalFirst = true;
[Tooltip("위로 떠오르는 속도(원본 1.0 · 캔버스 유닛/초).")]
public float damageRiseSpeed = 1.0f;
[Tooltip("크리티컬은 이 배율로 더 빨리 떠오른다(1 = 일반과 같음).")]
public float damageCritRiseMultiplier = 1.35f;
[Tooltip("표시 시간 대비 이 비율이 지난 뒤부터 알파가 빠지기 시작한다(0~1 · 원본은 처음부터 서서히).")]
public float damageFadeStartRatio = 0.55f;
[Tooltip("랜덤 산포 반경(px · 원본 ±25). 0 이면 정확히 대상 위. 겹침을 줄이려면 키운다.")]
public float damageScatterPx = 25f;
[Tooltip("크리티컬은 산포를 이 배율로 줄여 중앙에 가깝게 띄운다(눈에 잘 띄게).")]
public float damageCritScatterMultiplier = 0.4f;
[Tooltip("811e DamageAggregator.Flushed(합산 0.25 s)를 구독해 '합계 · N타' 를 함께 보여 준다. 데이터는 811e 소유 · 여기는 표시만.")]
public bool damageUseMergeWindow = true;
[Tooltip("합산 표시 형식 — {0} = 합계 · {1} = 타수. 빈 문자열이면 합계만 표시.")]
public string damageMergeFormat = "{0}";
[Tooltip("합산 타수가 이 값 이상일 때만 타수 꼬리표를 붙인다(damageMergeHitsSuffix).")]
public int damageMergeHitsMin = 2;
[Tooltip("타수 꼬리표 형식 — {0} = 타수. 예: \" x{0}\". 빈 문자열이면 붙이지 않는다.")]
public string damageMergeHitsSuffix = " <size=60%>x{0}</size>";
// ── ② 연쇄 처치 문구 (기준서 §B 요소 2 · 데이터는 811e KillChain) ──────
[Header("② 연쇄 처치 문구 (KillChain.TierReached 구독 · 화면 중앙 상단)")]
[Tooltip("연쇄 처치 문구 표시 여부.")]
public bool killChainTextEnabled = true;
[Tooltip("문구 표시 시간(초 · unscaled). 발주서 §1-1 = 0.8 s.")]
public float killChainSeconds = 0.8f;
[Tooltip("스케일 펀치 — 등장 순간의 배율(1.0 이면 펀치 없음).")]
public float killChainPunchScale = 1.45f;
[Tooltip("펀치가 1.0 으로 돌아오는 시간(초). 표시 시간보다 짧아야 한다.")]
public float killChainPunchSeconds = 0.18f;
[Tooltip("표시 시간 대비 이 비율이 지난 뒤부터 알파가 빠진다(0~1).")]
public float killChainFadeStartRatio = 0.6f;
[Tooltip("화면 중앙 기준 위쪽 오프셋(px · 세로 1080×1920 기준). Safe Area 안에서 잰다.")]
public float killChainTopOffsetPx = 520f;
[Tooltip("글자 크기(px · 세로 1080×1920 기준).")]
public float killChainFontPx = 96f;
[Tooltip("티어 인덱스별 글자 색(tiers 순서 = DOUBLE/TRIPLE/RAMPAGE/MASSACRE). 모자라면 마지막 색을 쓴다.")]
public Color[] killChainTierColors = new Color[]
{
new Color(1f, 0.94f, 0.72f, 1f),
new Color(1f, 0.80f, 0.35f, 1f),
new Color(1f, 0.52f, 0.22f, 1f),
new Color(1f, 0.27f, 0.28f, 1f),
};
[Tooltip("티어 인덱스별 글자 크기 배율(위 색과 같은 순서). 모자라면 마지막 값.")]
public float[] killChainTierScales = new float[] { 1f, 1.12f, 1.28f, 1.45f };
[Tooltip("문구 형식 — {0} = 티어 이름(811e 에셋의 tiers[i].name) · {1} = . PD Q5 ( §1-1).")]
public string killChainFormat = "{0}!";
[Tooltip("연쇄 킬 수를 아래 줄에 덧붙인다(빈 문자열이면 표시 안 함) — {0} = 킬 수.")]
public string killChainSubFormat = "<size=45%>{0} KILLS</size>";
// ── ③ 전리품 토스트 (기준서 §B 요소 4) ────────────────────────────────
[Header("③ 전리품 토스트 (획득 아이템 · 우측 · 등급 색)")]
[Tooltip("토스트 표시 여부.")]
public bool lootToastEnabled = true;
[Tooltip("한 줄이 남아 있는 시간(초). 기준서 §B 요소 4 = 1.2 s.")]
public float lootToastSeconds = 1.2f;
[Tooltip("동시에 보이는 줄 수. 기준서 §B 요소 4 = 3줄. 넘치면 가장 오래된 줄부터 밀려난다.")]
public int lootToastLines = 3;
[Tooltip("같은 아이템이 이 시간(초) 안에 또 들어오면 새 줄 대신 기존 줄의 수량을 올린다(줄 폭주 방지).")]
public float lootToastMergeSeconds = 1.0f;
[Tooltip("우측 여백(px · Safe Area 우측 기준).")]
public float lootToastRightMarginPx = 40f;
[Tooltip("첫 줄의 화면 세로 중앙 기준 오프셋(px · 양수 = 위).")]
public float lootToastCenterOffsetPx = 120f;
[Tooltip("줄 간격(px).")]
public float lootToastLineHeightPx = 54f;
[Tooltip("줄 글자 크기(px).")]
public float lootToastFontPx = 40f;
[Tooltip("줄 폭(px) — 오른쪽 정렬 기준의 상자 폭.")]
public float lootToastWidthPx = 520f;
[Tooltip("표시 시간 대비 이 비율이 지난 뒤부터 알파가 빠진다(0~1).")]
public float lootToastFadeStartRatio = 0.65f;
[Tooltip("등장할 때 오른쪽에서 밀려 들어오는 거리(px · 0 이면 슬라이드 없음).")]
public float lootToastSlideInPx = 60f;
[Tooltip("슬라이드가 끝나는 시간(초).")]
public float lootToastSlideSeconds = 0.14f;
[Tooltip("줄 형식 — {0} = 등급 색 태그 + 이름 · {1} = 개수. 개수 1 이면 lootToastFormatSingle 을 쓴다.")]
public string lootToastFormat = "{0}[-] x{1}";
[Tooltip("개수 1 일 때의 줄 형식 — {0} = 등급 색 태그 + 이름.")]
public string lootToastFormatSingle = "{0}[-]";
[Tooltip("이 등급 이상만 토스트로 띄운다(기준서 §B 요소 4 = 연출은 4 이상 · 토스트는 전부 보고 싶으면 1).")]
public int lootToastMinGrade = 1;
[Tooltip("골드(아이템 ID 2)도 토스트로 띄운다.")]
public bool lootToastIncludeGold = false;
[Tooltip("획득 집계를 초당 몇 번 확인할지(폴링 · DropItemInfo.m_StageDropData 델타). 813f 이벤트가 오면 이 폴링은 걷어낸다.")]
public float lootToastPollHz = 10f;
[Tooltip("등급 색 팔레트(index 0 = 등급 1). 비우면 MyValue.Get_GradeColor(코드 팔레트)를 그대로 쓴다.")]
public Color[] lootToastGradeColors = new Color[0];
// ── ④ 레벨업 스탯 팝업 (기준서 §B 요소 5) ─────────────────────────────
[Header("④ 레벨업 스탯 팝업 (CombatEvents.LevelUp 구독 · 스탯 3줄)")]
[Tooltip("스탯 팝업 표시 여부.")]
public bool statPopupEnabled = true;
[Tooltip("팝업이 남아 있는 시간(초 · unscaled).")]
public float statPopupSeconds = 2.0f;
[Tooltip("등장 스케일 펀치(1.0 이면 없음).")]
public float statPopupPunchScale = 1.3f;
[Tooltip("펀치가 1.0 으로 돌아오는 시간(초).")]
public float statPopupPunchSeconds = 0.22f;
[Tooltip("표시 시간 대비 이 비율이 지난 뒤부터 알파가 빠진다(0~1).")]
public float statPopupFadeStartRatio = 0.7f;
[Tooltip("화면 중앙 기준 위쪽 오프셋(px).")]
public float statPopupCenterOffsetPx = 180f;
[Tooltip("제목 글자 크기(px).")]
public float statPopupTitleFontPx = 72f;
[Tooltip("스탯 줄 글자 크기(px).")]
public float statPopupRowFontPx = 46f;
[Tooltip("스탯 줄 간격(px).")]
public float statPopupRowHeightPx = 56f;
[Tooltip("보여 줄 스탯 줄 수 상한. 기준서 §B 요소 5 = 3개.")]
public int statPopupRows = 3;
[Tooltip("보여 줄 스탯 종류(순서대로 · statPopupRows 개까지). 813k 가 이전/새 값을 주면 그 값을, 아니면 이 스탯의 스냅샷 차이를 쓴다.")]
public eStat[] statPopupStats = new eStat[] { eStat.STR, eStat.DEX, eStat.INT };
[Tooltip("증가분이 0 인 스탯 줄도 보여 준다.")]
public bool statPopupShowZero = false;
[Tooltip("제목 형식 — {0} = 새 레벨.")]
public string statPopupTitleFormat = "LEVEL {0}";
[Tooltip("스탯 줄 형식 — {0} = 스탯 이름 · {1} = 증가분 · {2} = 새 값.")]
public string statPopupRowFormat = "{0} +{1}";
[Tooltip("제목 색.")]
public Color statPopupTitleColor = new Color(1f, 0.90f, 0.45f, 1f);
[Tooltip("스탯 줄 색.")]
public Color statPopupRowColor = new Color(0.55f, 0.95f, 0.62f, 1f);
// ── 파생값 헬퍼 (코드 상수 0) ──────────────────────────────────────────
/// <summary>
/// px → 캔버스 유닛 계수. 813c(WLHudLayoutSettings.UnitsPerPx)와 **같은 산식**을 쓴다 —
/// CanvasScaler 설정을 재현해 구하고(에디트 모드처럼 캔버스가 레이아웃 전이어도 값이 나온다),
/// 스케일러가 없으면 캔버스 rect 폭으로 떨어진다. 세로 1080×1920 · 참조 1920×1080 Expand = 1.7778(802b 실측).
/// </summary>
public float UnitsPerPx(Canvas canvas)
{
if (designWidthPx <= 0f || designHeightPx <= 0f) return 1f;
var scaler = canvas != null ? canvas.GetComponent<CanvasScaler>() : null;
if (scaler != null && scaler.uiScaleMode == CanvasScaler.ScaleMode.ScaleWithScreenSize)
{
Vector2 r = scaler.referenceResolution;
if (r.x > 0f && r.y > 0f)
{
float w = designWidthPx, h = designHeightPx, scale;
switch (scaler.screenMatchMode)
{
case CanvasScaler.ScreenMatchMode.Expand: scale = Mathf.Min(w / r.x, h / r.y); break;
case CanvasScaler.ScreenMatchMode.Shrink: scale = Mathf.Max(w / r.x, h / r.y); break;
default: scale = Mathf.Pow(w / r.x, 1f - scaler.matchWidthOrHeight) *
Mathf.Pow(h / r.y, scaler.matchWidthOrHeight); break;
}
if (scale > 0f) return (w / scale) / designWidthPx;
}
}
var canvasRect = canvas != null ? canvas.transform as RectTransform : null;
if (canvasRect != null && canvasRect.rect.width > 0f) return canvasRect.rect.width / designWidthPx;
return 1f;
}
/// <summary>티어 인덱스의 색(배열이 짧으면 마지막 색 · 비어 있으면 흰색).</summary>
public Color TierColor(int tier)
{
if (killChainTierColors == null || killChainTierColors.Length == 0) return Color.white;
return killChainTierColors[Mathf.Clamp(tier, 0, killChainTierColors.Length - 1)];
}
/// <summary>티어 인덱스의 크기 배율(배열이 짧으면 마지막 값 · 비어 있으면 1).</summary>
public float TierScale(int tier)
{
if (killChainTierScales == null || killChainTierScales.Length == 0) return 1f;
return killChainTierScales[Mathf.Clamp(tier, 0, killChainTierScales.Length - 1)];
}
/// <summary>등급(1~9) 색. 팔레트가 비어 있으면 MyValue 코드 팔레트를 파싱해 쓴다.</summary>
public Color GradeColor(int grade)
{
if (lootToastGradeColors != null && lootToastGradeColors.Length > 0)
return lootToastGradeColors[Mathf.Clamp(grade - 1, 0, lootToastGradeColors.Length - 1)];
Color c;
var tag = MyValue.Get_GradeColor(grade); // "<color=#a6a6a7>"
int s = tag.IndexOf('#');
int e = tag.IndexOf('>');
if (s >= 0 && e > s && ColorUtility.TryParseHtmlString(tag.Substring(s, e - s), out c)) return c;
return Color.white;
}
/// <summary>등급의 리치텍스트 색 태그(원본 팔레트 그대로 · 기준서 §B 요소 4 「보유 팔레트 그대로」).</summary>
public string GradeColorTag(int grade) { return MyValue.Get_GradeColor(grade); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58612e2aec3ae5149af9f8fc191e6177

View File

@ -0,0 +1,147 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSkillSlotPreset.cs — 전투 패드 4슬롯 장착 **로컬 오버라이드** 값의 단일 출처 (WL-813g §3 · #813)
//
// 813c 완료보고 §5 이월분 + 813n 완료보고(슬롯 SkillID 16행 · 쿨 6/8/12/20 s · MP 10/15/25/40).
// 기준서 v1 §B 요소 3 「4슬롯 · 쿨·자원 차등」.
//
// ■ 왜 필요한가 (813n §1 「슬롯 장착은 서버 프리셋이라 813c 와 묶어야 함」)
// 장착 슬롯의 SOT 는 서버(`ServerData.Equip.Skill[Preset]`)다. 서버 프리셋을 바꾸지 않고도
// 전투 패드가 813n 프리셋(클래스별 Active 3 + 광역 1)으로 도는지 보려면 **클라 로컬 오버라이드**가 필요하다.
//
// ■ 어디에 쓰는가
// WLSkillSlotOverride(UI) 가 이 표에서 내 클래스 행을 찾아 4슬롯 SkillID 를
// `ServerData.Set_Equip(eItem.Skill, id, slot)`(클라 전용 공개 API · 서버 호출 0)로 써 넣는다.
// 그 뒤 `PCActor` 가 `Get_EquipSkillID(i)` 로 읽는 경로 = 기존 `Use_Skill` 경로 그대로다(핫스팟 0).
//
// ■ 롤백(C8)
// 이 에셋이 없거나 enabled = false 면 아무 것도 쓰지 않는다 = **서버 프리셋 원본** 그대로.
// restoreOnDisable 이 켜져 있으면 패드가 꺼질 때 원래 SkillID 를 되돌린다.
//
// 🔴 이 오버라이드는 **클라이언트 메모리만** 바꾼다. 서버 저장 호출(`Save_*`)은 하지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using UnityEngine;
namespace WL.UI
{
/// <summary>한 클래스(ClassConfig n_ClassID)의 4슬롯 장착 SkillID.</summary>
[Serializable]
public struct WLSkillSlotRow
{
[Tooltip("ClassConfig 의 n_ClassID (예: 10101 / 10102 / 10501 / 10502).")]
public int classId;
[Tooltip("메모용 이름(런타임 미사용).")]
public string note;
[Tooltip("슬롯 0~3 에 장착할 SkillID. 0 이면 그 슬롯은 건드리지 않는다(서버 값 유지).")]
public int[] skillIds;
}
[CreateAssetMenu(fileName = "WLSkillSlotPreset", menuName = "WL/Skill Slot Preset", order = 32)]
public sealed class WLSkillSlotPreset : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLSkillSlotPreset";
private static WLSkillSlotPreset s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 서버 프리셋 원본 유지 = C8 롤백).</summary>
public static WLSkillSlotPreset Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLSkillSlotPreset>(ResourcesPath);
}
return s_instance;
}
}
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>진단·A/B 전용 런타임 스위치.</summary>
public static bool RuntimeDisabled;
/// <summary>오버라이드가 살아 있는가 = 에셋 존재 + enabled + 런타임 스위치.</summary>
public static bool Enabled { get { var i = Instance; return i != null && i.overrideEnabled && !RuntimeDisabled; } }
// ── 스위치 ────────────────────────────────────────────────────────────
[Header("스위치")]
[Tooltip("끄면 서버 프리셋을 그대로 쓴다(오버라이드 0). 롤백 1순위.")]
public bool overrideEnabled = true;
[Tooltip("적용/차단 결정을 Console 에 남긴다(검증용).")]
public bool verboseLog = false;
[Tooltip("패드가 꺼질 때(씬 이동·컴포넌트 비활성) 원래 SkillID 를 되돌린다.")]
public bool restoreOnDisable = true;
[Tooltip("오버라이드할 슬롯 수(0~4). 원본 배열은 6칸이고 813c 가 쓰는 활성 슬롯은 4개다.")]
public int slotCount = 4;
// ── 클래스별 4슬롯 (813n 완료보고 16행) ───────────────────────────────
[Header("클래스별 4슬롯 SkillID (813n 프리셋 · 슬롯 1~3 = 클래스 Active · 슬롯 4 = 광역)")]
[Tooltip("클래스 행. 내 클래스(Actor.Get_ID())와 classId 가 같은 행을 쓴다. 없으면 오버라이드 0.")]
public WLSkillSlotRow[] rows = new WLSkillSlotRow[]
{
new WLSkillSlotRow { classId = 10101, note = "onehand · 703101/703102/703103 + 광역 702006 돌개바람", skillIds = new[] { 703101, 703102, 703103, 702006 } },
new WLSkillSlotRow { classId = 10102, note = "spellblade · 703106/703105/703104 + 광역 702009 마나폭발", skillIds = new[] { 703106, 703105, 703104, 702009 } },
new WLSkillSlotRow { classId = 10501, note = "bluntshield · 703502/703503/703501 + 광역 702005 파도", skillIds = new[] { 703502, 703503, 703501, 702005 } },
new WLSkillSlotRow { classId = 10502, note = "paladin · 703505/703506/703504 + 광역 702012 심판의빛", skillIds = new[] { 703505, 703506, 703504, 702012 } },
};
// ── MP 부족 시 버튼 흐림 (발주서 §3) ──────────────────────────────────
[Header("MP 게이트 — 부족하면 버튼 흐림 (Actor.cs:733-741 과 같은 식)")]
[Tooltip("MP 부족 표시 사용 여부.")]
public bool mpDimEnabled = true;
[Tooltip("MP 가 모자랄 때 버튼 알파(0~1 · 1 = 흐리지 않음).")]
public float mpDimAlpha = 0.4f;
[Tooltip("MP 가 충분할 때 버튼 알파(보통 1).")]
public float mpFullAlpha = 1f;
[Tooltip("알파가 바뀌는 데 걸리는 시간(초 · 0 이면 즉시).")]
public float mpDimLerpSeconds = 0.12f;
[Tooltip("MP 확인 주기(초). 매 프레임 확인하지 않는다.")]
public float mpCheckInterval = 0.1f;
[Tooltip("MP 부족 표시를 쿨타임 중에도 유지한다(off 면 쿨 중에는 쿨 링만 보여 준다).")]
public bool mpDimDuringCooldown = true;
// ── 헬퍼 ──────────────────────────────────────────────────────────────
/// <summary>클래스 ID 의 행을 찾는다. 없으면 -1.</summary>
public int FindRow(int classId)
{
if (rows == null) return -1;
for (int i = 0; i < rows.Length; i++) if (rows[i].classId == classId) return i;
return -1;
}
/// <summary>클래스 ID · 슬롯의 SkillID. 행이 없거나 슬롯이 비면 0(= 서버 값 유지).</summary>
public int SkillIdFor(int classId, int slot)
{
int r = FindRow(classId);
if (r < 0) return 0;
var ids = rows[r].skillIds;
if (ids == null || slot < 0 || slot >= ids.Length) return 0;
return ids[slot];
}
/// <summary>실제로 오버라이드할 슬롯 수(설정 값과 행 길이 중 작은 쪽).</summary>
public int SlotCountFor(int classId)
{
int r = FindRow(classId);
if (r < 0) return 0;
var ids = rows[r].skillIds;
return ids == null ? 0 : Mathf.Clamp(slotCount, 0, ids.Length);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a213dd34e76ecd43963c424aef9e4e6