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

494 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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);
else
{
for (int i = 0; i < count; i++) SpawnBurstOne(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);
}
/// <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건. 백을 다시 추첨하고, 빈손이면 골드로 대체한다. 러너가 간격을 두고 부른다.</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; } }
}
}