EerieVillage/Assets/Scripts/Skills/Runtime/PlayerSkillInventory.cs

366 lines
15 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.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills
{
/// <summary>
/// Player GameObject에 부착. 장착 스킬 슬롯·Lv·각성 상태 관리 중앙 컴포넌트.
/// BT12-Dev v1 §2-3·§4-3·§4-4 정합.
/// Phase 2-D에서 SkillCardPlaceholder·LevelUpManager와 정식 통합 예정.
/// </summary>
[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<IActiveSkill> _activeSkills = new List<IActiveSkill>();
private readonly List<IPassiveSkill> _passiveSkills = new List<IPassiveSkill>();
private readonly List<IAwakeningSkill> _awakenedSkills = new List<IAwakeningSkill>();
// 각 카드 ID → 런타임 인스턴스 매핑 (재픽 시 Lv 업용)
private readonly Dictionary<string, ISkillRuntime> _cardIdToRuntime = new Dictionary<string, ISkillRuntime>();
// 적 처치 누적 (OnKill 트리거용)
private int _totalKillCount = 0;
// Health 참조 — OnEnable에서 이벤트 구독
private Health _health;
/// <summary>통합 PlayerStats — 패시브 보정이 여기에 누적 적용</summary>
public PlayerStats Stats { get; private set; }
/// <summary>적 처치·피격 이벤트 구독자 (OnKill·OnHit 트리거용)</summary>
public event System.Action<EnemyKillContext> OnEnemyKilled;
public event System.Action<float> OnPlayerDamaged;
// ─────────────────────────────────────────────────────────────
// BT12-Dev-Clone (2026-05-15) — A10 분신 영역 (CloneInstance·CloneEffector hook)
// ─────────────────────────────────────────────────────────────
/// <summary>Player 스킬 발동 시 호출 (CloneInstance.EnqueuePlayerFire hook).</summary>
public event System.Action<ActiveSkillRuntime> OnPlayerSkillFired;
/// <summary>분신 발동 컨텍스트 분기 플래그. Effector 영역 anchor·facing·damage 분기 적용. (BT12-Dev-Clone 2026-05-18 fix: internal → public · Test assembly 접근 영역)</summary>
public bool IsCloneFireActive = false;
/// <summary>분신 발동 시 anchor 위치 (분신 GameObject 위치).</summary>
public Vector2 CloneFireOrigin = Vector2.zero;
/// <summary>분신 발동 시 facing sign (-1 = 왼쪽·+1 = 오른쪽). spawn 시점 고정 (PD 결정 2026-05-15).</summary>
public float CloneFireFacingX = 1f;
/// <summary>PD 결정 (2026-05-15) — 분신 damage multiplier. 50% 반감.</summary>
public const float CLONE_DAMAGE_MULTIPLIER = 0.5f;
/// <summary>
/// OnPlayerSkillFired 외부 invoke 영역 (BT12-Dev-Clone 2026-05-18 fix).
/// C# event 영역 외부 클래스 영역 .Invoke() 직접 호출 불가 (CS0070) 영역 fix.
/// ActiveSkillRuntime.Fire 영역 본 메서드 호출 → 분신 hook 발화.
/// </summary>
public void RaisePlayerSkillFired(ActiveSkillRuntime runtime)
{
OnPlayerSkillFired?.Invoke(runtime);
}
/// <summary>
/// 스킬 발동 anchor 위치 반환 (BT12-Dev-Clone 2026-05-15 γ 단계).
/// IsCloneFireActive=true → 분신 위치 (CloneFireOrigin) · false → Player 위치.
/// 6 Effector 영역 일관 사용 — Player·분신 발동 위치 분기 단일 진입점.
/// </summary>
public Vector2 GetSpawnAnchor()
{
return IsCloneFireActive ? CloneFireOrigin : (Vector2)transform.position;
}
/// <summary>
/// 스킬 발동 facing 반환 (BT12-Dev-Clone 2026-05-15 γ 단계).
/// IsCloneFireActive=true → 분신 facing 고정 (CloneFireFacingX) · false → Player facing.
/// 6 Effector 영역 일관 사용.
/// </summary>
public Vector2 GetSpawnFacing(PlayerController pc)
{
if (IsCloneFireActive) return new Vector2(CloneFireFacingX, 0f);
return pc != null ? pc.Facing : Vector2.right;
}
// 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<Health>();
}
// 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<string> StaleSpawnNames =
new System.Collections.Generic.HashSet<string>
{
"Hitbox_Debug",
"ProjectileHitbox_Debug",
"LaserHitbox_Debug",
"MeleeHitbox_Debug",
"MeleeHitbox_Debug2",
"Range_Debug",
// PD 지시 2026-05-14 — A06·A11 판정 박스 시각화 신규
"PoisonSwampHitbox_Debug",
"SpiritFireHitbox_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<GameObject>();
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<GameObject> targets)
{
if (root == null) return;
// Projectile 및 파생 component 부착 → 이 root 단위 destroy
if (root.GetComponent<EerieVillage.Skills.Effectors.Projectile>() != 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
});
}
}
}
/// <summary>
/// 픽 UI 선택 결과 반영 — cardId로 SkillDataAsset 조회 후 런타임 생성·장착.
/// Phase 2-D에서 SkillCardPlaceholder·LevelUpManager 통합 hook 연결 예정.
/// </summary>
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;
}
/// <summary>
/// 적 처치 이벤트 외부 호출 (EnemyController 사망 시 연결 예정 — Phase 2-D).
/// OnKill 트리거 액티브·패시브 발동.
/// </summary>
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<IActiveSkill> ActiveSkills => _activeSkills;
public IReadOnlyList<IPassiveSkill> PassiveSkills => _passiveSkills;
}
/// <summary>적 처치 컨텍스트 (Phase 2-D에서 EnemyController 참조 추가 예정)</summary>
public struct EnemyKillContext
{
public string EnemyId;
public int XPReward;
}
}