366 lines
15 KiB
C#
366 lines
15 KiB
C#
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;
|
||
}
|
||
}
|