using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills
{
///
/// Player GameObject에 부착. 장착 스킬 슬롯·Lv·각성 상태 관리 중앙 컴포넌트.
/// BT12-Dev v1 §2-3·§4-3·§4-4 정합.
/// Phase 2-D에서 SkillCardPlaceholder·LevelUpManager와 정식 통합 예정.
///
[RequireComponent(typeof(Health))]
public class PlayerSkillInventory : MonoBehaviour
{
[Header("슬롯 상한 (balance-designer 재확인 — VS 원작 6/6 참고)")]
public int ActiveSlotMax = 6;
public int PassiveSlotMax = 6;
// PD 지시 2026-05-13 — 게임 시작 시 기본 습득 스킬 CardId 배열 (Inspector 조절·다중 지원)
[Header("기본 습득 스킬 (게임 시작 시 자동 장착)")]
[Tooltip("게임 시작 시 자동 습득할 스킬 CardId 배열 (예: A02 파이어볼·중복·잘못된 ID 자동 skip)")]
public string[] StartingCardIds = new string[] { "A02" };
// 장착 슬롯 (인덱스 = 슬롯 번호)
private readonly List _activeSkills = new List();
private readonly List _passiveSkills = new List();
private readonly List _awakenedSkills = new List();
// 각 카드 ID → 런타임 인스턴스 매핑 (재픽 시 Lv 업용)
private readonly Dictionary _cardIdToRuntime = new Dictionary();
// 적 처치 누적 (OnKill 트리거용)
private int _totalKillCount = 0;
// Health 참조 — OnEnable에서 이벤트 구독
private Health _health;
/// 통합 PlayerStats — 패시브 보정이 여기에 누적 적용
public PlayerStats Stats { get; private set; }
/// 적 처치·피격 이벤트 구독자 (OnKill·OnHit 트리거용)
public event System.Action OnEnemyKilled;
public event System.Action OnPlayerDamaged;
// PD 지시 2026-05-14 — Scene load 직후 정적 cleanup (가장 이른 진입점·Awake 보다 선행)
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.AfterSceneLoad)]
static void OnSceneLoadedStaticCleanup()
{
int removed = DoCleanupStalePooledSpawns(true); // useDestroyImmediate=true (정적·Awake 이전)
Debug.Log("[PlayerSkillInventory] AfterSceneLoad static cleanup removed=" + removed);
}
void Awake()
{
// PD 지시 2026-05-14 — 게임 재실행 시 이전 Play 잔존 spawn (투사체·박스·Range) 강제 cleanup.
// 이중 방어: AfterSceneLoad 정적 cleanup 외 Awake·1 frame 후 (Coroutine) 추가 cleanup.
int removed = DoCleanupStalePooledSpawns(false);
Debug.Log("[PlayerSkillInventory] Awake cleanup removed=" + removed);
StartCoroutine(DelayedCleanupCoroutine());
Stats = new PlayerStats();
PlayerStats.Current = Stats;
_health = GetComponent();
}
// PD 지시 2026-05-14 — 1·3·10 frame 후 추가 cleanup. Awake 시점 미발화 spawn·다른 컴포넌트 Awake/Start spawn 모두 catch.
System.Collections.IEnumerator DelayedCleanupCoroutine()
{
int[] delays = { 1, 3, 10 };
foreach (int d in delays)
{
for (int i = 0; i < d; i++) yield return null;
int removed = DoCleanupStalePooledSpawns(false);
if (removed > 0)
Debug.Log("[PlayerSkillInventory] Delayed cleanup frame=" + d + " removed=" + removed);
}
}
// PD 지시 2026-05-14 — 잔존 풀링 GameObject 강제 cleanup
// 대상: Projectile 및 파생 (HomingProjectile·PiercingProjectile) + 박스·Range·FX (Clone) 시각화 GameObject
static readonly System.Collections.Generic.HashSet StaleSpawnNames =
new System.Collections.Generic.HashSet
{
"Hitbox_Debug",
"ProjectileHitbox_Debug",
"LaserHitbox_Debug",
"MeleeHitbox_Debug",
"Range_Debug",
};
// PD 지시 2026-05-14 — FX clone 카탈로그 (Projectile component 미부착 — Cast FX·HitFx 등)
static readonly string[] StaleClonePrefixes =
{
"FX_Fireball_Bullet",
"FX_Lightningball",
"FX_Dragonfire",
"FX_Thunder",
"FX_SLASH",
"FX_PinkMagicArrow",
"Projectile_", // CreateFallbackProjectile name 패턴
};
static int DoCleanupStalePooledSpawns(bool useImmediate)
{
int removed = 0;
// PD 지시 2026-05-14 — Scene root 재귀 탐색 (Resources.FindObjectsOfTypeAll 영역 hideFlags hidden GO 누락 우려·정확도 ↑)
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
if (!scene.IsValid()) return 0;
var roots = scene.GetRootGameObjects();
// 재귀 cleanup 대상 수집 (직접 destroy 시 enumeration 중 collection 변경 회피)
var targets = new System.Collections.Generic.List();
foreach (var root in roots)
{
CollectStaleRecursive(root, targets);
}
foreach (var go in targets)
{
if (go == null) continue;
if (useImmediate) DestroyImmediate(go); else Destroy(go);
removed++;
}
return removed;
}
static void CollectStaleRecursive(GameObject root, System.Collections.Generic.List targets)
{
if (root == null) return;
// Projectile 및 파생 component 부착 → 이 root 단위 destroy
if (root.GetComponent() != null)
{
targets.Add(root);
return; // 자식 재귀 불필요 (root destroy 시 자식 자동 destroy)
}
// Name 매칭 (박스·Range·FX clone)
if (IsStaleByName(root.name))
{
targets.Add(root);
return;
}
// 자식 재귀
foreach (Transform child in root.transform)
{
CollectStaleRecursive(child.gameObject, targets);
}
}
static bool IsStaleByName(string name)
{
if (StaleSpawnNames.Contains(name)) return true;
if (!name.EndsWith("(Clone)")) return name.StartsWith("Projectile_");
foreach (var pre in StaleClonePrefixes)
{
if (name.StartsWith(pre)) return true;
}
return false;
}
// PD 지시 2026-05-13 — Start 시점에 기본 습득 스킬 자동 장착 (Resources 로드 완료 후·Awake 영역 아님)
void Start()
{
if (StartingCardIds == null) return;
foreach (var cardId in StartingCardIds)
{
if (string.IsNullOrEmpty(cardId)) continue;
AddSkillByCardId(cardId);
}
}
void OnEnable()
{
if (_health != null)
{
_health.OnDamagedEvent += OnPlayerDamagedHandler;
}
}
void OnDisable()
{
if (_health != null)
{
_health.OnDamagedEvent -= OnPlayerDamagedHandler;
}
}
void Update()
{
// 액티브 Cooldown 감산·OnTime 발동
foreach (var active in _activeSkills)
{
active.Tick(Time.deltaTime);
}
// 조건부 패시브 타이머 감산 (P17 감로수 등 OnTimer 계열)
foreach (var passive in _passiveSkills)
{
if (!passive.IsAlwaysOn)
{
passive.OnTrigger(new PassiveTriggerContext
{
Kind = PassiveTriggerKind.OnTimer,
TimeElapsed = Time.deltaTime
});
}
}
}
///
/// 픽 UI 선택 결과 반영 — cardId로 SkillDataAsset 조회 후 런타임 생성·장착.
/// Phase 2-D에서 SkillCardPlaceholder·LevelUpManager 통합 hook 연결 예정.
///
public bool AddSkillByCardId(string cardId)
{
if (string.IsNullOrEmpty(cardId)) return false;
// 재픽 = Lv 업
if (_cardIdToRuntime.TryGetValue(cardId, out var existing))
{
existing.Upgrade();
return true;
}
var data = SkillRuntimeFactory.Resolve(cardId);
if (data == null)
{
Debug.LogWarning($"[PlayerSkillInventory] CardId '{cardId}' 에 해당하는 SkillDataAsset을 찾을 수 없습니다.");
return false;
}
var runtime = SkillRuntimeFactory.Create(data);
if (runtime == null) return false;
if (runtime is IActiveSkill active)
{
if (_activeSkills.Count >= ActiveSlotMax)
{
Debug.LogWarning($"[PlayerSkillInventory] 액티브 슬롯 초과 (상한 {ActiveSlotMax}).");
return false;
}
runtime.OnEquip(this);
_activeSkills.Add(active);
_cardIdToRuntime[cardId] = runtime;
return true;
}
if (runtime is IPassiveSkill passive)
{
if (_passiveSkills.Count >= PassiveSlotMax)
{
Debug.LogWarning($"[PlayerSkillInventory] 패시브 슬롯 초과 (상한 {PassiveSlotMax}).");
return false;
}
runtime.OnEquip(this);
passive.ApplyModifier(Stats);
_passiveSkills.Add(passive);
_cardIdToRuntime[cardId] = runtime;
return true;
}
return false;
}
///
/// 적 처치 이벤트 외부 호출 (EnemyController 사망 시 연결 예정 — Phase 2-D).
/// OnKill 트리거 액티브·패시브 발동.
///
public void NotifyEnemyKilled(EnemyKillContext ctx)
{
_totalKillCount++;
OnEnemyKilled?.Invoke(ctx);
foreach (var active in _activeSkills.Where(a => a.Trigger == ActiveTrigger.OnKill))
active.Fire();
foreach (var passive in _passiveSkills.Where(p => !p.IsAlwaysOn))
passive.OnTrigger(new PassiveTriggerContext
{
Kind = PassiveTriggerKind.OnEnemyKilled,
KillCount = _totalKillCount
});
}
// Health.OnDamagedEvent 구독 핸들러
private void OnPlayerDamagedHandler(int damage)
{
OnPlayerDamaged?.Invoke(damage);
foreach (var active in _activeSkills.Where(a => a.Trigger == ActiveTrigger.OnHit))
active.Fire();
foreach (var passive in _passiveSkills.Where(p => !p.IsAlwaysOn))
passive.OnTrigger(new PassiveTriggerContext
{
Kind = PassiveTriggerKind.OnPlayerDamaged,
DamageTaken = damage
});
}
// --- 읽기 전용 접근자 (디버그·UI 용) ---
public IReadOnlyList ActiveSkills => _activeSkills;
public IReadOnlyList PassiveSkills => _passiveSkills;
}
/// 적 처치 컨텍스트 (Phase 2-D에서 EnemyController 참조 추가 예정)
public struct EnemyKillContext
{
public string EnemyId;
public int XPReward;
}
}