// ───────────────────────────────────────────────────────────────────────────── // LootBurst.cs — 전리품 연출(드랍 산포 · 등급 빔 + 사운드 · 자동 줍기 반경 · 보스/엘리트 폭발 드랍) // // PD 지시 #813 · 발주서 WL-813f §1 (2026-09-09) · 기준서 v1 §B 요소 4 · §D-1 813f 행 // // ■ 무엇을 하나 // ① 산포 — DropItem.Set() 의 훅 1줄이 시작점(튀는 높이)·목표점(0.6~1.2 m 링)을 우리 값으로 바꾼다. // 원본 포물선 코루틴(ProjectileBase.Co_Move_Split)을 그대로 쓴다 = 새 이동 코드 0. // ② 등급 빔 — table_itemlist.n_ItemGrade ≥ beamMinGrade(4) 면 보유 FX_LootDrop_* 프리팹을 드랍에 붙이고 // eSound.s015_GoldPowerUp 을 낸다. EffectBudget.Heavy 슬롯을 잡고, 드랍이 사라지면 파괴 + 반납. // ③ 자동 줍기 — 원본 자석 상태기(DropItem.Update 의 m_Magnet 1→2)를 **리플렉션으로 켜기만** 한다. // 실제 획득은 원본 콜라이더 → ProcessCollider → ItemTouch 그대로(획득 로직 무수정). // ④ 폭발 드랍 — CombatEvents.Killed 구독. 엘리트 1~2 · 보스 3~5 건을 몹의 RandomBag 재추첨으로 추가한다. // 빈손이면 골드(n_DropGold × burstGoldShare)로 대체. 원본 1건 추첨은 그대로 둔다(감소 0). // ⑤ 813g 연결 — Dropped / Picked 두 이벤트를 코어의 CombatEventList(제네릭 · GC 0)로 공개한다. // // ■ 원본 수정량 // Assets/Script/DropItem/DropItem.cs **2줄**(Set 의 산포 훅 1 · ItemTouch 의 획득 통지 훅 1). // Actor.cs · MobActor.cs · DropItemInfo.cs · Assets/WL/Combat/Core/** 는 0줄. // // ■ 롤백(C8) // WLLootSettings 에셋이 없거나 enabled = false(또는 RuntimeDisabled) 면 훅 2줄이 첫 줄에서 반환하고 // Killed 구독자도 즉시 반환한다 → 산포·빔·줍기·폭발이 전부 사라지고 원본 드랍 100% 로 돌아간다. // // ■ 에디트 모드(프로브) // 코루틴이 돌지 않으므로 폭발 드랍은 즉시(간격 0) 처리하고, 빔은 스폰만 한 뒤 프로브가 CleanupAll() 로 정리한다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System; using System.Reflection; using UnityEngine; using UnityEngine.AI; using WL.Combat.Core; namespace WL.Combat.Loot { /// 드랍이 화면에 나온 순간. 813g 토스트는 이걸 구독해도 되고, 획득 시점(Picked)만 써도 된다. public struct LootDropEvent { public DropItem item; public int itemId; public int amount; public int grade; public Vector3 position; public bool fromBurst; public bool beamSpawned; public float time; public int frame; } /// 드랍을 실제로 주운 순간(원본 ItemTouch 가 서버 지급을 마친 직후). **813g 전리품 토스트의 소스**. public struct LootPickedEvent { public DropItem item; public Actor picker; public int itemId; public int amount; public int grade; public Vector3 position; public bool isGold; public float time; public int frame; } public static class LootBurst { /// 골드 아이템 ID(원본 DropItem.Set / ItemTouch 의 `case 2`). public const int GoldItemId = 2; // ── 813g(U) 연결 API — 구독은 Dropped.Add(handler) / Picked.Add(handler), 해제는 Remove. public static readonly CombatEventList Dropped = new CombatEventList(4); public static readonly CombatEventList Picked = new CombatEventList(4); // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int KilledHandled, DropsSeen, Scattered, BeamSpawned, BeamSkippedBudget, BeamSkippedNoPrefab, BeamSkippedCrowded, SoundPlayed, PickedCount, MagnetEngaged, BurstPlanned, BurstDropped, BurstSkippedCap, BurstSkippedBudget; public static float LastScatterDistance, LastBounceHeight; public static int LastGrade, LastBurstCount; public static string LastNote = ""; // ── 같은 처치에서 여러 개가 떨어질 때 부채꼴로 흩기 위한 상태 static int s_fanFrame = -1, s_fanIndex; static float s_fanBaseAngle; // ── 폭발 드랍 컨텍스트(Killed → 곧바로 이어지는 원본 드랍이 "이 처치의 것" 임을 알기 위해) static int s_lastKillFrame = -1; static eSubRol s_lastKillSubRole; static float s_lastSoundTime = -999f; static LootBurstRunner s_runner; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void RegisterOnLoad() { EnsureSubscribed(); } /// 구독 보장(중복 구독은 CombatEventList 가 막는다). 에디트 모드 프로브도 이걸 부른다. public static void EnsureSubscribed() { CombatEvents.Killed.Add(OnKilled); Subscribed = true; } /// 구독 해제(프로브 종료용). public static void Unsubscribe() { CombatEvents.Killed.Remove(OnKilled); Subscribed = false; } internal static LootBurstRunner Runner { get { if (s_runner == null) { var go = new GameObject("__WLLootBurst"); // Play 중에만 DontDestroyOnLoad(LevelUpBurst 선례). 에디트 모드(프로브)에서는 씬을 더럽히지 않도록 DontSave. if (Application.isPlaying) UnityEngine.Object.DontDestroyOnLoad(go); else go.hideFlags = HideFlags.DontSave; s_runner = go.AddComponent(); } return s_runner; } } /// 러너를 만들지 않고 현재 인스턴스만 본다(에디트 모드 진단용). public static LootBurstRunner RunnerOrNull { get { return s_runner; } } // ═════════════════════════════════════════════════ 원본 훅 ① DropItem.Set (산포) /// /// DropItem.Set() 안에서 원본이 계산한 시작점·목표점을 우리 값으로 바꾼다(훅 1줄). /// 꺼져 있으면 아무것도 하지 않는다 = 원본 위치 그대로. /// public static void OnDropSpawn(DropItem item, int itemId, int amount, ref Vector3 startPos, ref Vector3 targetPos) { if (!WLLootSettings.Enabled || item == null) return; var s = WLLootSettings.Instance; DropsSeen++; int grade = ResolveGrade(itemId); LastGrade = grade; bool fromBurst = s_lastKillFrame == Time.frameCount; if (s.scatterEnabled) { Vector3 origin = startPos; Vector3 flat = SampleScatter(s, origin); float bounce = Mathf.Min(s.bounceHeightMax, s.bounceHeightBase + Mathf.Max(0, grade - 1) * s.bounceHeightPerGrade); startPos = origin + Vector3.up * bounce; targetPos = flat + Vector3.up * 0.25f; // 원본 골드와 같은 착지 오프셋 LastScatterDistance = Vector3.Distance(new Vector3(origin.x, 0f, origin.z), new Vector3(flat.x, 0f, flat.z)); LastBounceHeight = bounce; Scattered++; } bool beam = TrySpawnBeam(s, item, grade); Runner.Register(item); var e = new LootDropEvent { item = item, itemId = itemId, amount = amount, grade = grade, position = targetPos, fromBurst = fromBurst, beamSpawned = beam, time = Time.unscaledTime, frame = Time.frameCount }; Dropped.Dispatch(in e); if (s.verboseLog) Debug.Log("[LootBurst] drop id=" + itemId + " grade=" + grade + " scatter=" + LastScatterDistance.ToString("F2") + "m bounce=" + LastBounceHeight.ToString("F2") + "m beam=" + beam + " burst=" + fromBurst); } // ═════════════════════════════════════════════════ 원본 훅 ② DropItem.ItemTouch (획득 통지) /// /// 원본 ItemTouch 가 서버 지급(Add_Item · StageDropData)을 마친 직후 부른다(훅 1줄). /// 813g 전리품 토스트는 이 이벤트만 구독하면 된다 — 획득 수량은 골드 보정까지 끝난 최종값이다. /// public static void OnDropPicked(DropItem item, int itemId, int amount, Actor picker) { if (!WLLootSettings.Enabled) return; PickedCount++; int grade = ResolveGrade(itemId); var e = new LootPickedEvent { item = item, picker = picker, itemId = itemId, amount = amount, grade = grade, position = item != null ? item.transform.position : Vector3.zero, isGold = itemId == GoldItemId, time = Time.unscaledTime, frame = Time.frameCount }; Picked.Dispatch(in e); if (s_runner != null) s_runner.Unregister(item); var s = WLLootSettings.Instance; if (s != null && s.verboseLog) Debug.Log("[LootBurst] picked id=" + itemId + " x" + amount + " grade=" + grade); } // ═════════════════════════════════════════════════ Killed 구독자 (폭발 드랍) static void OnKilled(in KilledEvent e) { var s = WLLootSettings.Instance; if (s == null || !s.enabled || WLLootSettings.RuntimeDisabled) return; KilledHandled++; s_lastKillFrame = Time.frameCount; s_lastKillSubRole = e.subRole; NewFanGroup(); if (!s.burstEnabled) return; if (e.victim == null || e.victim.n_Reincarnation > 0) return; // 원본 Set_Die 와 같은 조건(부활 몹은 드랍 없음) int count = BurstCount(s, e.subRole); LastBurstCount = count; if (count <= 0) return; if (s.maxConcurrentDrops > 0 && ActiveDropCount() >= s.maxConcurrentDrops) { BurstSkippedCap++; LastNote = "cap"; return; } bool held = false; if (s.burstUseClusterBudget) { if (!EffectBudget.TryAcquire(EffectBudgetKind.ClusterBurst)) { BurstSkippedBudget++; LastNote = "cluster-budget"; return; } held = true; } BurstPlanned += count; var appear = e.victim.Get_MonsterStatTableData(); int bagId = appear != null ? appear.n_DropReward : 0; int baseGold = appear != null ? appear.n_DropGold : 0; if (Application.isPlaying && s.burstIntervalSeconds > 0f) Runner.RunBurst(count, bagId, baseGold, e.position, s.burstIntervalSeconds, held, e.subRole); else { for (int i = 0; i < count; i++) SpawnBurstOne(BagForBurst(s, e.subRole, i, bagId), baseGold, e.position); if (held) EffectBudget.Release(EffectBudgetKind.ClusterBurst); } if (s.verboseLog) Debug.Log("[LootBurst] burst " + e.subRole + " x" + count + " bag=" + bagId + " gold=" + baseGold); } /// 등급/역할별 추가 드랍 건수(포함 범위 · min > max 면 min 으로 클램프). public static int BurstCount(WLLootSettings s, eSubRol subRole) { int min, max; switch (subRole) { case eSubRol.Boss: min = s.bossBurstMin; max = s.bossBurstMax; break; case eSubRol.Elite: min = s.eliteBurstMin; max = s.eliteBurstMax; break; default: return Mathf.Max(0, s.normalBurstCount); } min = Mathf.Max(0, min); max = Mathf.Max(min, max); return UnityEngine.Random.Range(min, max + 1); } /// /// 이 폭발 드랍 1건이 쓸 백. 보스는 전부 bossBagId(등급 ≥5), 엘리트는 앞 eliteFloorDrops 건만 eliteBagId(등급 ≥3), /// 나머지는 몹의 존 백 그대로. ID 0 = 기존 동작(C8). /// public static int BagForBurst(WLLootSettings s, eSubRol subRole, int index, int zoneBagId) { if (s == null) return zoneBagId; if (subRole == eSubRol.Boss && s.bossBagId > 0) return s.bossBagId; if (subRole == eSubRol.Elite && s.eliteBagId > 0 && index < s.eliteFloorDrops) return s.eliteBagId; return zoneBagId; } /// 폭발 드랍 1건. 백을 다시 추첨하고, 빈손이면 골드로 대체한다. 러너가 간격을 두고 부른다. internal static void SpawnBurstOne(int bagId, int baseGold, Vector3 position) { var s = WLLootSettings.Instance; if (s == null || DropItemInfo.Ins == null) return; if (s.maxConcurrentDrops > 0 && ActiveDropCount() >= s.maxConcurrentDrops) { BurstSkippedCap++; return; } int itemId = 0, amount = 0; if (s.burstRollsBag && bagId > 0 && !RollBag(bagId, out itemId, out amount)) itemId = 0; if (itemId <= 0) { if (s.burstGoldShare <= 0f) return; itemId = GoldItemId; amount = Mathf.Max(1, Mathf.RoundToInt(baseGold * s.burstGoldShare)); } s_lastKillFrame = Time.frameCount; // 이 드랍도 "이 처치의 것" 으로 표시(부채꼴 유지) DropItemInfo.Ins.Drop_DropItem(itemId, amount, position); BurstDropped++; } /// 원본 MobActor.Set_Die 와 같은 방식(행 순서대로 독립 시행 · 첫 성공에서 멈춤)으로 백을 추첨한다. public static bool RollBag(int bagId, out int itemId, out int amount) { itemId = 0; amount = 0; if (table_randombag.Ins == null) return false; System.Collections.Generic.List rows; try { rows = table_randombag.Ins.Get_DataList(bagId); } catch (Exception) { return false; } if (rows == null) return false; for (int i = 0; i < rows.Count; i++) { if (DSUtil.RandomTrue(rows[i].f_DropRate)) { itemId = rows[i].n_ItemId; amount = rows[i].n_DropCount; return true; } } return false; } // ═════════════════════════════════════════════════ 산포 /// /// 드랍 지점 주위 링(min~max)에서 한 점을 고르고 NavMesh 로 스냅한다. /// DSUtil.Get_RandomPos_onNavMesh 를 쓰지 않는 이유: 그 함수는 maxradius 를 스냅 반경으로 그대로 써서 /// 결과가 원점에서 최대 2×maxradius 까지 튄다(DSUtil.cs:969 주석의 실측). 여기서는 반경을 정확히 지킨다. /// alloc 0(NavMeshHit 은 struct · 경로 계산 없음). /// public static Vector3 SampleScatter(WLLootSettings s, Vector3 origin) { float radius = UnityEngine.Random.Range(s.scatterRadiusMin, s.scatterRadiusMax); float angle = NextFanAngle(s); Vector3 p = origin + new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius; if (s.scatterNavSnapRadius > 0f) { NavMeshHit hit; if (NavMesh.SamplePosition(p, out hit, s.scatterNavSnapRadius, NavMesh.AllAreas)) return hit.position; // NavMesh 밖(또는 NavMesh 자체가 없는 씬) → 원점 높이를 유지한 링 위치를 그대로 쓴다. p.y = origin.y; } return p; } /// 같은 프레임(= 같은 처치)의 드랍들을 황금각으로 흩는다. 새 프레임이면 기준 각을 새로 뽑는다. static float NextFanAngle(WLLootSettings s) { if (!s.fanOutSameKill) return UnityEngine.Random.Range(0f, Mathf.PI * 2f); if (s_fanFrame != Time.frameCount) NewFanGroup(); float a = s_fanBaseAngle + s_fanIndex * 2.39996f; // 황금각(rad) — 3~5건이 고르게 퍼진다 s_fanIndex++; return a; } static void NewFanGroup() { s_fanFrame = Time.frameCount; s_fanIndex = 0; s_fanBaseAngle = UnityEngine.Random.Range(0f, Mathf.PI * 2f); } // ═════════════════════════════════════════════════ 등급 빔 + 사운드 static bool TrySpawnBeam(WLLootSettings s, DropItem item, int grade) { if (grade < s.beamMinGrade) return false; var prefab = BeamPrefab(s, grade); if (prefab == null) { BeamSkippedNoPrefab++; return false; } if (s.beamSuppressAboveDrops > 0 && ActiveDropCount() > s.beamSuppressAboveDrops) { BeamSkippedCrowded++; return false; } bool held = false; if (s.beamUseEffectBudget) { if (!EffectBudget.TryAcquire(EffectBudgetKind.Heavy)) { BeamSkippedBudget++; return false; } held = true; } Transform parent = s.beamParentToDrop ? item.transform : null; var go = UnityEngine.Object.Instantiate(prefab, item.transform.position + s.beamOffset, Quaternion.identity, parent); if (s.beamScale > 0f && Mathf.Abs(s.beamScale - 1f) > 0.0001f) go.transform.localScale = prefab.transform.localScale * s.beamScale; Runner.RegisterBeam(item, go, held, s.beamMaxLifetimeSeconds); BeamSpawned++; PlayBeamSound(s, item.transform.position); return true; } /// 등급 → 빔 프리팹(인덱스 = 등급 − 1). 배열이 짧거나 칸이 비어 있으면 null. public static GameObject BeamPrefab(WLLootSettings s, int grade) { if (s.beamByGrade == null) return null; int idx = grade - 1; if (idx < 0 || idx >= s.beamByGrade.Length) return null; return s.beamByGrade[idx]; } static void PlayBeamSound(WLLootSettings s, Vector3 pos) { if (s.beamSoundVolume <= 0f) return; float now = Time.unscaledTime; if (s.beamSoundMinIntervalSeconds > 0f && now - s_lastSoundTime < s.beamSoundMinIntervalSeconds) return; if (SoundInfo.Ins == null) return; s_lastSoundTime = now; SoundInfo.Ins.Play_OneShot_byDistance(s.beamSound, pos, s.beamSoundVolume); SoundPlayed++; } // ═════════════════════════════════════════════════ 등급 조회 /// 아이템 등급(table_itemlist.n_ItemGrade). 테이블이 없거나 미등록 ID 면 1. public static int ResolveGrade(int itemId) { if (itemId == GoldItemId) return 1; if (table_itemlist.Ins == null) return 1; try { var d = table_itemlist.Ins.Get_Data(itemId); return d != null ? Mathf.Clamp(d.n_ItemGrade, 1, WLLootSettings.GradeSlots) : 1; } catch (Exception) { return 1; } } // ═════════════════════════════════════════════════ 자동 줍기 — 원본 자석 상태기 켜기(리플렉션) static FieldInfo s_fMagnet, s_fMagnetUp, s_fMagnetUpTime, s_fProjectileData; static FieldInfo s_fSpeed; static bool s_reflectionDone, s_reflectionOk; static void EnsureReflection() { if (s_reflectionDone) return; s_reflectionDone = true; const BindingFlags F = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; var t = typeof(DropItem); s_fMagnet = t.GetField("m_Magnet", F); s_fMagnetUp = t.GetField("v3_MagnetUP", F); s_fMagnetUpTime = t.GetField("MagnetUpTime", F); s_fProjectileData = typeof(ProjectileBase).GetField("_projectileData", F); if (s_fProjectileData != null) s_fSpeed = s_fProjectileData.FieldType.GetField("Speed", F); s_reflectionOk = s_fMagnet != null && s_fMagnetUp != null; if (!s_reflectionOk) LastNote = "reflection-miss"; } /// 리플렉션으로 원본 자석 필드를 찾았는가(프로브가 읽는다). public static bool ReflectionOk { get { EnsureReflection(); return s_reflectionOk; } } /// 이 드랍이 이미 자석 상태인가(0 = 아님). public static int MagnetState(DropItem item) { EnsureReflection(); if (!s_reflectionOk || item == null) return -1; return (int)s_fMagnet.GetValue(item); } /// /// 자동 줍기 판정(러너와 프로브가 같은 식을 쓴다). /// 착지가 끝나(원본이 m_Collider 를 켠) 아직 자석이 아닌 드랍이 PC 중심 반경 안에 있으면 true. /// public static bool InPickupRadius(DropItem item, Vector3 pcCenter, WLLootSettings s) { if (item == null || s == null) return false; if (item.m_Collider == null || !item.m_Collider.enabled) return false; return (item.transform.position - pcCenter).sqrMagnitude <= s.autoPickupRadius * s.autoPickupRadius; } /// /// 원본 자석 상태기를 1단계로 켠다(원본 DropItem.cs:81-91 의 주석 코드와 같은 값 세팅). /// 이동·획득은 전부 원본 Update / ProcessCollider 가 한다 — 우리는 스위치만 켠다. /// public static bool EngageMagnet(DropItem item) { EnsureReflection(); if (!s_reflectionOk || item == null) return false; var s = WLLootSettings.Instance; if (s == null) return false; if ((int)s_fMagnet.GetValue(item) != 0) return false; s_fMagnetUp.SetValue(item, item.transform.position + Vector3.up * s.magnetRiseHeight); if (s_fMagnetUpTime != null) s_fMagnetUpTime.SetValue(item, 0f); if (s_fProjectileData != null && s_fSpeed != null) { var pd = s_fProjectileData.GetValue(item); if (pd != null) s_fSpeed.SetValue(pd, s.magnetRiseSpeed); } s_fMagnet.SetValue(item, 1); MagnetEngaged++; return true; } // ═════════════════════════════════════════════════ 진단 · 정리 /// 지금 살아 있는(활성) 드랍 오브젝트 수. 러너가 세는 값(원본 풀을 훑지 않는다 = alloc 0). public static int ActiveDropCount() { return s_runner != null ? s_runner.ActiveCount : 0; } /// 빔 슬롯을 몇 개 들고 있나(러너 기준). public static int HeldBeamBudget { get { return s_runner != null ? s_runner.HeldBudget : 0; } } /// 프로브·씬 전환용: 빔을 전부 파괴하고 EffectBudget 슬롯을 반납한다. public static void CleanupAll() { if (s_runner != null) s_runner.CleanupAll(); } /// 프로브용: 카운터 초기화(구독·러너는 유지). public static void ResetDiagnostics() { KilledHandled = DropsSeen = Scattered = BeamSpawned = BeamSkippedBudget = BeamSkippedNoPrefab = BeamSkippedCrowded = SoundPlayed = PickedCount = MagnetEngaged = BurstPlanned = BurstDropped = BurstSkippedCap = BurstSkippedBudget = 0; LastScatterDistance = LastBounceHeight = 0f; LastGrade = LastBurstCount = 0; LastNote = ""; s_lastSoundTime = -999f; s_lastKillFrame = -1; s_lastKillSubRole = eSubRol.None; Dropped.Raised = Dropped.Dispatched = 0; Picked.Raised = Picked.Dispatched = 0; } /// 마지막으로 통지된 처치의 역할(프로브 검증용). public static eSubRol LastKillSubRole { get { return s_lastKillSubRole; } } } }