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)")] // PD 지시 2026-05-14 — 기본 공격 파이어볼 발사 X (테스트 영역) public string[] StartingCardIds = new string[] { }; // 장착 슬롯 (인덱스 = 슬롯 번호) 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; } }