Project_WL/Assets/WL/Combat/Loot/LootBurst.cs

506 lines
26 KiB
C#
Raw Permalink Normal View History

[WL-813f] 전리품 연출 + 드랍률 (#813) CombatEvents.Killed 구독 → 드랍 산포(0.6~1.2 m · 등급별 튀는 높이 0.35+0.08/등급) · 등급 4 이상 빔(보유 FX_LootDrop_Blue/Purple/Gold) + s015_GoldPowerUp · 자동 줍기 반경 1.5 m(원본 자석 상태기를 리플렉션으로 켜기만) · 보스 3~5 · 엘리트 1~2 폭발 드랍(RandomBag 재추첨 · 빈손이면 골드 25%). 값은 전부 WLLootSettings.asset(C45) · 동시 빔은 EffectBudget.Heavy 등록. 원본 수정 = DropItem.cs 2줄(Set 산포 훅 · ItemTouch 획득 통지 훅) 뿐. Actor.cs · MobActor.cs · DropItemInfo.cs · Assets/WL/Combat/Core/** 0줄. 데이터(S): RandomBag.json 새 백 8130001 5행(총 드랍 20.95% · 기준서 15~25%) · MonsterAppear.json 813e 존 6행(813001~813005 · 813010)의 n_DropReward 만 새 백으로 · 나머지 46행 · RandomBag 기존 197행 바이트 무변경. ⚠ butler.xlsm 동기화 필요. 검증: 게이트 PASS(oneshot 1.4분 · error CS 0 / resident up_to_date · 콘솔 error 0) · 에디트 모드 프로브(AgentScripts/WL813f_Probe.cs · 덤프 WL813f_PROBE.txt): 산포 1000회 0.601~1.199 m · 폭발 2000회 boss 3~5 · elite 1~2 · normal 0 · 가짜 Killed 3건 구독 동작 · 자석 리플렉션 OK(0→1 · 중복 방지) · 줍기 경계 1.49 in / 1.51 out / 착지 전 제외 · 빔 4건 → Heavy 상한 2 스폰 · 2 스킵 → CleanupAll 후 슬롯 0 · C8(RuntimeDisabled) 시 산포/빔/획득/폭발 전부 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:48 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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<T>(제네릭 · 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
{
/// <summary>드랍이 화면에 나온 순간. 813g 토스트는 이걸 구독해도 되고, 획득 시점(Picked)만 써도 된다.</summary>
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;
}
/// <summary>드랍을 실제로 주운 순간(원본 ItemTouch 가 서버 지급을 마친 직후). **813g 전리품 토스트의 소스**.</summary>
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
{
/// <summary>골드 아이템 ID(원본 DropItem.Set / ItemTouch 의 `case 2`).</summary>
public const int GoldItemId = 2;
// ── 813g(U) 연결 API — 구독은 Dropped.Add(handler) / Picked.Add(handler), 해제는 Remove.
public static readonly CombatEventList<LootDropEvent> Dropped = new CombatEventList<LootDropEvent>(4);
public static readonly CombatEventList<LootPickedEvent> Picked = new CombatEventList<LootPickedEvent>(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(); }
/// <summary>구독 보장(중복 구독은 CombatEventList 가 막는다). 에디트 모드 프로브도 이걸 부른다.</summary>
public static void EnsureSubscribed()
{
CombatEvents.Killed.Add(OnKilled);
Subscribed = true;
}
/// <summary>구독 해제(프로브 종료용).</summary>
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<LootBurstRunner>();
}
return s_runner;
}
}
/// <summary>러너를 만들지 않고 현재 인스턴스만 본다(에디트 모드 진단용).</summary>
public static LootBurstRunner RunnerOrNull { get { return s_runner; } }
// ═════════════════════════════════════════════════ 원본 훅 ① DropItem.Set (산포)
/// <summary>
/// DropItem.Set() 안에서 원본이 계산한 시작점·목표점을 우리 값으로 바꾼다(훅 1줄).
/// 꺼져 있으면 아무것도 하지 않는다 = 원본 위치 그대로.
/// </summary>
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 (획득 통지)
/// <summary>
/// 원본 ItemTouch 가 서버 지급(Add_Item · StageDropData)을 마친 직후 부른다(훅 1줄).
/// 813g 전리품 토스트는 이 이벤트만 구독하면 된다 — 획득 수량은 골드 보정까지 끝난 최종값이다.
/// </summary>
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);
[WL-813f] 전리품 연출 + 드랍률 (#813) CombatEvents.Killed 구독 → 드랍 산포(0.6~1.2 m · 등급별 튀는 높이 0.35+0.08/등급) · 등급 4 이상 빔(보유 FX_LootDrop_Blue/Purple/Gold) + s015_GoldPowerUp · 자동 줍기 반경 1.5 m(원본 자석 상태기를 리플렉션으로 켜기만) · 보스 3~5 · 엘리트 1~2 폭발 드랍(RandomBag 재추첨 · 빈손이면 골드 25%). 값은 전부 WLLootSettings.asset(C45) · 동시 빔은 EffectBudget.Heavy 등록. 원본 수정 = DropItem.cs 2줄(Set 산포 훅 · ItemTouch 획득 통지 훅) 뿐. Actor.cs · MobActor.cs · DropItemInfo.cs · Assets/WL/Combat/Core/** 0줄. 데이터(S): RandomBag.json 새 백 8130001 5행(총 드랍 20.95% · 기준서 15~25%) · MonsterAppear.json 813e 존 6행(813001~813005 · 813010)의 n_DropReward 만 새 백으로 · 나머지 46행 · RandomBag 기존 197행 바이트 무변경. ⚠ butler.xlsm 동기화 필요. 검증: 게이트 PASS(oneshot 1.4분 · error CS 0 / resident up_to_date · 콘솔 error 0) · 에디트 모드 프로브(AgentScripts/WL813f_Probe.cs · 덤프 WL813f_PROBE.txt): 산포 1000회 0.601~1.199 m · 폭발 2000회 boss 3~5 · elite 1~2 · normal 0 · 가짜 Killed 3건 구독 동작 · 자석 리플렉션 OK(0→1 · 중복 방지) · 줍기 경계 1.49 in / 1.51 out / 착지 전 제외 · 빔 4건 → Heavy 상한 2 스폰 · 2 스킵 → CleanupAll 후 슬롯 0 · C8(RuntimeDisabled) 시 산포/빔/획득/폭발 전부 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:48 +00:00
else
{
for (int i = 0; i < count; i++) SpawnBurstOne(BagForBurst(s, e.subRole, i, bagId), baseGold, e.position);
[WL-813f] 전리품 연출 + 드랍률 (#813) CombatEvents.Killed 구독 → 드랍 산포(0.6~1.2 m · 등급별 튀는 높이 0.35+0.08/등급) · 등급 4 이상 빔(보유 FX_LootDrop_Blue/Purple/Gold) + s015_GoldPowerUp · 자동 줍기 반경 1.5 m(원본 자석 상태기를 리플렉션으로 켜기만) · 보스 3~5 · 엘리트 1~2 폭발 드랍(RandomBag 재추첨 · 빈손이면 골드 25%). 값은 전부 WLLootSettings.asset(C45) · 동시 빔은 EffectBudget.Heavy 등록. 원본 수정 = DropItem.cs 2줄(Set 산포 훅 · ItemTouch 획득 통지 훅) 뿐. Actor.cs · MobActor.cs · DropItemInfo.cs · Assets/WL/Combat/Core/** 0줄. 데이터(S): RandomBag.json 새 백 8130001 5행(총 드랍 20.95% · 기준서 15~25%) · MonsterAppear.json 813e 존 6행(813001~813005 · 813010)의 n_DropReward 만 새 백으로 · 나머지 46행 · RandomBag 기존 197행 바이트 무변경. ⚠ butler.xlsm 동기화 필요. 검증: 게이트 PASS(oneshot 1.4분 · error CS 0 / resident up_to_date · 콘솔 error 0) · 에디트 모드 프로브(AgentScripts/WL813f_Probe.cs · 덤프 WL813f_PROBE.txt): 산포 1000회 0.601~1.199 m · 폭발 2000회 boss 3~5 · elite 1~2 · normal 0 · 가짜 Killed 3건 구독 동작 · 자석 리플렉션 OK(0→1 · 중복 방지) · 줍기 경계 1.49 in / 1.51 out / 착지 전 제외 · 빔 4건 → Heavy 상한 2 스폰 · 2 스킵 → CleanupAll 후 슬롯 0 · C8(RuntimeDisabled) 시 산포/빔/획득/폭발 전부 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:48 +00:00
if (held) EffectBudget.Release(EffectBudgetKind.ClusterBurst);
}
if (s.verboseLog)
Debug.Log("[LootBurst] burst " + e.subRole + " x" + count + " bag=" + bagId + " gold=" + baseGold);
}
/// <summary>등급/역할별 추가 드랍 건수(포함 범위 · min > max 면 min 으로 클램프).</summary>
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);
}
/// <summary>
/// 이 폭발 드랍 1건이 쓸 백. 보스는 전부 bossBagId(등급 ≥5), 엘리트는 앞 eliteFloorDrops 건만 eliteBagId(등급 ≥3),
/// 나머지는 몹의 존 백 그대로. ID 0 = 기존 동작(C8).
/// </summary>
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;
}
[WL-813f] 전리품 연출 + 드랍률 (#813) CombatEvents.Killed 구독 → 드랍 산포(0.6~1.2 m · 등급별 튀는 높이 0.35+0.08/등급) · 등급 4 이상 빔(보유 FX_LootDrop_Blue/Purple/Gold) + s015_GoldPowerUp · 자동 줍기 반경 1.5 m(원본 자석 상태기를 리플렉션으로 켜기만) · 보스 3~5 · 엘리트 1~2 폭발 드랍(RandomBag 재추첨 · 빈손이면 골드 25%). 값은 전부 WLLootSettings.asset(C45) · 동시 빔은 EffectBudget.Heavy 등록. 원본 수정 = DropItem.cs 2줄(Set 산포 훅 · ItemTouch 획득 통지 훅) 뿐. Actor.cs · MobActor.cs · DropItemInfo.cs · Assets/WL/Combat/Core/** 0줄. 데이터(S): RandomBag.json 새 백 8130001 5행(총 드랍 20.95% · 기준서 15~25%) · MonsterAppear.json 813e 존 6행(813001~813005 · 813010)의 n_DropReward 만 새 백으로 · 나머지 46행 · RandomBag 기존 197행 바이트 무변경. ⚠ butler.xlsm 동기화 필요. 검증: 게이트 PASS(oneshot 1.4분 · error CS 0 / resident up_to_date · 콘솔 error 0) · 에디트 모드 프로브(AgentScripts/WL813f_Probe.cs · 덤프 WL813f_PROBE.txt): 산포 1000회 0.601~1.199 m · 폭발 2000회 boss 3~5 · elite 1~2 · normal 0 · 가짜 Killed 3건 구독 동작 · 자석 리플렉션 OK(0→1 · 중복 방지) · 줍기 경계 1.49 in / 1.51 out / 착지 전 제외 · 빔 4건 → Heavy 상한 2 스폰 · 2 스킵 → CleanupAll 후 슬롯 0 · C8(RuntimeDisabled) 시 산포/빔/획득/폭발 전부 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:48 +00:00
/// <summary>폭발 드랍 1건. 백을 다시 추첨하고, 빈손이면 골드로 대체한다. 러너가 간격을 두고 부른다.</summary>
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++;
}
/// <summary>원본 MobActor.Set_Die 와 같은 방식(행 순서대로 독립 시행 · 첫 성공에서 멈춤)으로 백을 추첨한다.</summary>
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<RandomBagTableData> 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;
}
// ═════════════════════════════════════════════════ 산포
/// <summary>
/// 드랍 지점 주위 링(min~max)에서 한 점을 고르고 NavMesh 로 스냅한다.
/// DSUtil.Get_RandomPos_onNavMesh 를 쓰지 않는 이유: 그 함수는 maxradius 를 스냅 반경으로 그대로 써서
/// 결과가 원점에서 최대 2×maxradius 까지 튄다(DSUtil.cs:969 주석의 실측). 여기서는 반경을 정확히 지킨다.
/// alloc 0(NavMeshHit 은 struct · 경로 계산 없음).
/// </summary>
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;
}
/// <summary>같은 프레임(= 같은 처치)의 드랍들을 황금각으로 흩는다. 새 프레임이면 기준 각을 새로 뽑는다.</summary>
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;
}
/// <summary>등급 → 빔 프리팹(인덱스 = 등급 1). 배열이 짧거나 칸이 비어 있으면 null.</summary>
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++;
}
// ═════════════════════════════════════════════════ 등급 조회
/// <summary>아이템 등급(table_itemlist.n_ItemGrade). 테이블이 없거나 미등록 ID 면 1.</summary>
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";
}
/// <summary>리플렉션으로 원본 자석 필드를 찾았는가(프로브가 읽는다).</summary>
public static bool ReflectionOk { get { EnsureReflection(); return s_reflectionOk; } }
/// <summary>이 드랍이 이미 자석 상태인가(0 = 아님).</summary>
public static int MagnetState(DropItem item)
{
EnsureReflection();
if (!s_reflectionOk || item == null) return -1;
return (int)s_fMagnet.GetValue(item);
}
/// <summary>
/// 자동 줍기 판정(러너와 프로브가 같은 식을 쓴다).
/// 착지가 끝나(원본이 m_Collider 를 켠) 아직 자석이 아닌 드랍이 PC 중심 반경 안에 있으면 true.
/// </summary>
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;
}
/// <summary>
/// 원본 자석 상태기를 1단계로 켠다(원본 DropItem.cs:81-91 의 주석 코드와 같은 값 세팅).
/// 이동·획득은 전부 원본 Update / ProcessCollider 가 한다 — 우리는 스위치만 켠다.
/// </summary>
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;
}
// ═════════════════════════════════════════════════ 진단 · 정리
/// <summary>지금 살아 있는(활성) 드랍 오브젝트 수. 러너가 세는 값(원본 풀을 훑지 않는다 = alloc 0).</summary>
public static int ActiveDropCount() { return s_runner != null ? s_runner.ActiveCount : 0; }
/// <summary>빔 슬롯을 몇 개 들고 있나(러너 기준).</summary>
public static int HeldBeamBudget { get { return s_runner != null ? s_runner.HeldBudget : 0; } }
/// <summary>프로브·씬 전환용: 빔을 전부 파괴하고 EffectBudget 슬롯을 반납한다.</summary>
public static void CleanupAll() { if (s_runner != null) s_runner.CleanupAll(); }
/// <summary>프로브용: 카운터 초기화(구독·러너는 유지).</summary>
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;
}
/// <summary>마지막으로 통지된 처치의 역할(프로브 검증용).</summary>
public static eSubRol LastKillSubRole { get { return s_lastKillSubRole; } }
}
}