docs(BT12-Dev Phase 2-A): Skills 13 파일 신규 (인터페이스·SO·중앙 컴포넌트)

C49 Phase 2 (집행) — Sonnet 위임 결과·Phase 1 dev-team-lead 재분석 보고서 정합.

신설 13 파일 (Assets/Scripts/Skills/):
- Interfaces/ (4): ISkillRuntime, IActiveSkill, IPassiveSkill, IAwakeningSkill (+ ActiveTrigger·PassiveTriggerKind·AwakeningPattern enum)
- Data/ (4): SkillDataAsset (abstract·AttributeTag·TypeTag), ActiveSkillData (Category 6종·14 신규 필드), PassiveSkillData (StatType·stub), AwakeningSkillData (stub)
- Runtime/ (4): PlayerStats (POCO·AttributeTag Dictionary), ActiveSkillRuntime (Tick·Fire·EffectiveCooldown 하드캡 0.5s·StackLevelFactor), PlayerSkillInventory ([RequireComponent(Health)]·OnDamagedEvent 구독·NotifyEnemyKilled), SkillRuntimeFactory (Resolve·Create·stub 2종)
- Events/ (1): SkillFireEvent (Simulation.Event<T>·Execute stub·카테고리 분기 6종 주석)

설계서 정합:
- §2-1 인터페이스 계약 (ISkillRuntime → IActiveSkill·IPassiveSkill·IAwakeningSkill)
- §2-2 ScriptableObject 계약 (ActiveCategory 6종·CreateAssetMenu 3종)
- §2-3 PlayerStats POCO·AttributeTag 키 Dictionary
- §3-2 CSV 매핑 테이블·§3-3 Resolve+Create 분기
- §4-2 EffectiveCooldown = BaseCooldown × CooldownMultiplier ÷ StackLevelFactor·하드캡 0.5s
- §4-4 OnHit·OnKill 이벤트 핸들러 PlayerSkillInventory 구현

설계서 대비 조정 3건 (Sonnet 자체 정합):
1. IPassiveSkill.ApplyTo → ApplyModifier·RemoveModifier (설계서 §2-1 명세 정합)
2. AddSkillByCardId 반환 void → bool (실패 감지)
3. EnemyKillContext struct 신설 (Phase 2-D 정식 통합 전 decoupling)

Phase 2-B 준비:
- SkillFireEvent.Execute stub 영역 카테고리 분기 6종 주석
- Phase 2-B 투사체 진입 시 ProjectileSpawner·AttackHitbox 연결 지점 명확

기존 파일 영역 변경 X (BT12-MVP-A·BT5-Dev·BT7-Dev 미변경)
회귀 위험 = 매우 낮음 (신규 파일만)

C50 분량 (PD 사전 승인 80~120K) — 실제 ~73K (정합)
PD 결정 (b 5분할·b-1 카테고리 6분할·우선 투사체) 사전 승인 정합

pm-auditor 사전 감사 = Pass 4 + Minor 1 + Major 1
- Major 1 정정 영역 = git add 명시 path 한정 (Skills 디렉토리만·Screenshots·_Recovery 미포함) 
- Minor 1 후속 영역 = PD Editor Refresh 후 read_console 본 PM 직접 실측

untracked 영역 별도 안건:
- Assets/Screenshots/ (manage_camera screenshot 영역·.gitignore 검토 영역)
- Assets/_Recovery/ (Unity 자동 복구 파일·.gitignore 검토 영역)
This commit is contained in:
깃 관리자 2026-05-09 18:31:38 +09:00
parent 2783c15d56
commit 87710bac58
30 changed files with 1044 additions and 0 deletions

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 515f8dec2e303b44495b79dc3bb4f5ec
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,115 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 액티브 스킬 ScriptableObject 데이터.
/// BT12-Dev v1 §2-2 정합. ActiveCategory 6종·ActiveTrigger 3종.
/// </summary>
[CreateAssetMenu(fileName = "Active_", menuName = "EerieVillage/Skills/Active")]
public class ActiveSkillData : SkillDataAsset
{
[Header("액티브 전용")]
/// <summary>카테고리 (A~F) — Projectile·MeleeArea·PlacementPersistent·Minion·Debuff·SpecialJudge</summary>
public ActiveCategory Category;
/// <summary>발동 트리거 — OnTime·OnHit·OnKill</summary>
public ActiveTrigger Trigger;
[Tooltip("Lv.1 기본 쿨다운 (초). balance/01 v0.2 BaseCooldown 1.5s 참조")]
public float BaseCooldown = 1.5f;
[Tooltip("Lv.1 기본 대미지 (쿼터 단위). balance/01 v0.2 BaseDamage 10 참조")]
public int BaseDamage = 10;
[Tooltip("판정 박스 기본 크기 (A·B 카테고리). balance/01 v0.2 AttackBoxSize 1.5x1.0 참조")]
public Vector2 HitboxSize = new Vector2(1.5f, 1.0f);
[Tooltip("투사체 발사 오프셋 거리 (A 카테고리)")]
public float OffsetDistance = 0.5f;
[Tooltip("투사체 전용 (A 카테고리). 궤적 타입 — Line·Homing·Arc")]
public ProjectileTrajectory Trajectory = ProjectileTrajectory.Line;
[Tooltip("소환 전용 (D 카테고리). 소환물 프리팹 참조")]
public GameObject MinionPrefab;
[Tooltip("연쇄 타격 횟수 (Homing 등 연쇄 계열)")]
public int ChainCount = 0;
[Tooltip("DoT 지속 시간 (초)")]
public float DotDuration = 0f;
[Tooltip("DoT 타격 간격 (초)")]
public float DotInterval = 0.5f;
[Tooltip("기절 지속 시간 (초)")]
public float StunDuration = 0f;
[Tooltip("감속 지속 시간 (초)")]
public float SlowDuration = 0f;
[Tooltip("감속 배율 (0.5 = 50% 감속)")]
[Range(0f, 1f)]
public float SlowMultiplier = 0.5f;
[Tooltip("넉백 힘")]
public float KnockbackForce = 0f;
[Tooltip("동시 최대 인스턴스 수 (C·D 카테고리)")]
public int MaxConcurrent = 1;
[Tooltip("소환물 수명 (초, D 카테고리)")]
public float MinionLifetime = 10f;
[Tooltip("오라 주기 (초, E 카테고리 아우라형)")]
public float AuraTickInterval = 0.5f;
[Tooltip("오라 반경 (E 카테고리 아우라형)")]
public float AuraRadius = 3f;
[Tooltip("크리티컬 대미지 배율")]
public float CritDamageMultiplier = 2.0f;
[Tooltip("무적 프레임 부여 시간 (초, F 카테고리)")]
public float IFrameDuration = 0f;
[Tooltip("상태이상 스택 한도 (E 카테고리)")]
public int DebuffStackLimit = 3;
[Tooltip("확률 판정 전용 (F 카테고리). 발동 확률 0~1")]
[Range(0f, 1f)]
public float FireProbability = 1.0f;
}
/// <summary>
/// 액티브 스킬 카테고리 (A~F). BT12-Dev v1 §2-2 정합.
/// </summary>
public enum ActiveCategory
{
/// <summary>A. 투사체</summary>
Projectile,
/// <summary>B. 근접·범위</summary>
MeleeArea,
/// <summary>C. 설치·지속</summary>
PlacementPersistent,
/// <summary>D. 소환</summary>
Minion,
/// <summary>E. 상태이상</summary>
Debuff,
/// <summary>F. 특수 판정</summary>
SpecialJudge
}
/// <summary>
/// 투사체 궤적 타입. BT12-Dev v1 §2-2 정합.
/// </summary>
public enum ProjectileTrajectory
{
/// <summary>직선</summary>
Line,
/// <summary>유도</summary>
Homing,
/// <summary>곡선</summary>
Arc
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69566f3f65e99394d8a0ccd0b395ac77

View File

@ -0,0 +1,28 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 각성 스킬 ScriptableObject 데이터.
/// BT12-Dev v1 §2-2 정합. Phase 2 범위 외 stub — Phase 2-C 이후 필드 확장 예정.
/// </summary>
[CreateAssetMenu(fileName = "Awakening_", menuName = "EerieVillage/Skills/Awakening")]
public class AwakeningSkillData : SkillDataAsset
{
[Header("각성 전용")]
/// <summary>진화 패턴 (1 스케일업 · 2 새효과 · 3 다중 발동 · 4 광역 확산)</summary>
public AwakeningPattern Pattern;
/// <summary>진화 대상 원 액티브 데이터</summary>
public ActiveSkillData OriginalActive;
/// <summary>필요 패시브 후보 (1개 이상 보유로 조건 충족)</summary>
public PassiveSkillData[] RequiredPassives;
[Tooltip("각성 후 기본 대미지 (원 액티브 Lv.5 대비 2~3배 권장, balance/01 v0.2 §3 참조)")]
public int AwakeningBaseDamage = 25;
[Tooltip("각성 연출 프리팹 (풀스크린)")]
public GameObject AwakeningEffectPrefab;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a27193dd65b4d8b4ea089434bcd4f012

View File

@ -0,0 +1,64 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 패시브 스킬 ScriptableObject 데이터.
/// BT12-Dev v1 §2-2 정합. Phase 2 범위 외 stub — Phase 2-C 이후 필드 확장 예정.
/// </summary>
[CreateAssetMenu(fileName = "Passive_", menuName = "EerieVillage/Skills/Passive")]
public class PassiveSkillData : SkillDataAsset
{
[Header("패시브 전용")]
/// <summary>패시브 카테고리 (P-A~P-E)</summary>
public PassiveCategory Category;
/// <summary>상시 적용 여부 (true = 장착 즉시 효과 · false = 조건부)</summary>
public bool IsAlwaysOn = true;
[Tooltip("대상 스탯 (P-A·P-C 전용)")]
public StatType TargetStat;
[Tooltip("Lv.1 기본 보정값 (대미지 배율·하트 수·확률 등)")]
public float BaseModifierValue;
[Tooltip("조건부 패시브 (P11·P16·P17 등) 트리거 종류")]
public PassiveTriggerKind TriggerKind;
}
/// <summary>
/// 패시브 카테고리 (P-A~P-E). BT12-Dev v1 §2-2 정합.
/// </summary>
public enum PassiveCategory
{
/// <summary>P-A. 스탯 상승</summary>
StatUp,
/// <summary>P-B. 주기 단축·증폭</summary>
CycleAmplify,
/// <summary>P-C. 생존 강화</summary>
Survival,
/// <summary>P-D. 회복</summary>
Recovery,
/// <summary>P-E. 자원 확장</summary>
ResourceExpand
}
/// <summary>
/// 대상 스탯 종류. BT12-Dev v1 §2-2 정합.
/// </summary>
public enum StatType
{
Damage,
AttackSpeed,
MoveSpeed,
MaxHearts,
CritChance,
CritDamage,
DamageReduction,
Evasion,
IFrameExtend,
JumpHeight,
XPGain,
TreasureFind
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26160e8ad6b27fd429558597e70481ba

View File

@ -0,0 +1,61 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 모든 스킬 데이터 asset의 공통 base.
/// BT12-Dev v1 §2-2 정합.
/// </summary>
public abstract class SkillDataAsset : ScriptableObject
{
[Header("공통")]
/// <summary>카드 식별자 — "A01"·"P12"·"AW19" (CSV v0.3 ID 컬럼)</summary>
public string CardId;
/// <summary>플레이어 표시 이름 (한글) — "진언부(眞言符)"</summary>
public string DisplayName;
/// <summary>영문 표시명 — CSV 2번째 컬럼</summary>
public string EnglishName;
/// <summary>UI용 아이콘</summary>
public Sprite Icon;
/// <summary>플레이어 표시 툴팁</summary>
[TextArea(2, 4)]
public string Description;
/// <summary>속성 태그 (Flags) — [물리]·[화염] 등</summary>
public AttributeTag AttributeTags;
/// <summary>타입 태그 (Flags) — [근접]·[원거리] 등</summary>
public TypeTag TypeTags;
/// <summary>최대 스택 레벨. 기본 5. Upgrade() 상한 기준.</summary>
[Tooltip("최대 스택 레벨 (1~5). 기본값 5.")]
public int maxLevel = 5;
}
/// <summary>
/// 속성 태그 (Flags enum). BT12-Dev v1 §2-2 정합.
/// </summary>
[System.Flags]
public enum AttributeTag
{
None = 0,
Physical = 1 << 0,
Fire = 1 << 1,
Frost = 1 << 2,
Lightning = 1 << 3,
Dark = 1 << 4
}
/// <summary>
/// 타입 태그 (Flags enum). BT12-Dev v1 §2-2 정합.
/// </summary>
[System.Flags]
public enum TypeTag
{
None = 0,
Melee = 1 << 0,
Ranged = 1 << 1,
Area = 1 << 2,
Persistent = 1 << 3,
Recovery = 1 << 4,
Defense = 1 << 5
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e745a0332e778014fb747e65ab25a5df

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3f85e7a7bd0c8cd49bc6308aee2d80d7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,51 @@
using Platformer.Core;
namespace EerieVillage.Skills
{
/// <summary>
/// 스킬 발동 이벤트. Simulation.Event&lt;T&gt; 계승.
/// ActiveSkillRuntime.Fire()에서 Simulation.Schedule&lt;SkillFireEvent&gt;() 호출.
/// BT12-Dev v1 §3-4 정합.
///
/// Phase 2-A: Execute = stub (카테고리 분기 구조만 명시).
/// Phase 2-B: 카테고리별 실 발동기 (ProjectileSpawner·AttackHitbox 등) 연결 예정.
/// </summary>
public class SkillFireEvent : Simulation.Event<SkillFireEvent>
{
/// <summary>발동 요청한 액티브 스킬 런타임</summary>
public ActiveSkillRuntime Runtime;
/// <summary>플레이어 스킬 인벤토리 (Stats·위치·방향 조회 경로)</summary>
public PlayerSkillInventory Inventory;
public override void Execute()
{
if (Runtime == null) return;
// Phase 2-B 카테고리별 실 발동기 호출 예정 영역
// 현재 Phase 2-A = 구조 stub만 배치.
//
// switch (Runtime.ActiveData.Category)
// {
// case ActiveCategory.Projectile:
// ProjectileSpawner.Spawn(Runtime, Inventory); break;
// case ActiveCategory.MeleeArea:
// AttackHitbox.Fire(Runtime, Inventory); break;
// case ActiveCategory.PlacementPersistent:
// AuraZone.Place(Runtime, Inventory); break;
// case ActiveCategory.Minion:
// MinionSpawner.Spawn(Runtime, Inventory); break;
// case ActiveCategory.Debuff:
// DebuffApplier.Apply(Runtime, Inventory); break;
// case ActiveCategory.SpecialJudge:
// SpecialJudgeHandler.Execute(Runtime, Inventory); break;
// }
}
internal override void Cleanup()
{
Runtime = null;
Inventory = null;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7554b04b9e2dae24cab5b9443ddafa4b

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d99e4b20f4f653b4c89e657df4e8af8c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,43 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 액티브 스킬 런타임 계약. 주기·이벤트 트리거로 효과 발동.
/// BT12-Dev v1 §2-1 정합.
/// </summary>
public interface IActiveSkill : ISkillRuntime
{
/// <summary>기본 쿨다운(초). balance/01 v0.2 BaseCooldown 1.5s 기반</summary>
float BaseCooldown { get; }
/// <summary>패시브 보정 후 실제 쿨다운 (연사술 P06 적용 결과)</summary>
float EffectiveCooldown { get; }
/// <summary>현재 Cooldown 타이머(초). MonoBehaviour Update가 감산</summary>
float CooldownRemaining { get; }
/// <summary>쿨다운 경과 또는 트리거 이벤트 시 호출. 효과 발동 Dispatch</summary>
void Fire();
/// <summary>트리거 이벤트 구독 — OnHit·OnKill·OnTime 중 해당</summary>
ActiveTrigger Trigger { get; }
/// <summary>PlayerSkillInventory.Update가 매 프레임 호출. OnTime 트리거 타이머 감산</summary>
void Tick(float deltaTime);
}
/// <summary>
/// 액티브 발동 트리거 분류 — 카테고리·카드에 따라 결정.
/// BT12-Dev v1 §2-1 정합.
/// </summary>
public enum ActiveTrigger
{
/// <summary>주기 타이머 (카테고리 A·B·C·D·E 기본)</summary>
OnTime,
/// <summary>플레이어 피격 시 (카테고리 F·P11 호신부 등)</summary>
OnHit,
/// <summary>적 처치 시 (P16 단전수련 등 패시브도 동일 enum 재사용)</summary>
OnKill
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5033e69ecbd4a384789b4e44953fc5ef

View File

@ -0,0 +1,39 @@
namespace EerieVillage.Skills
{
/// <summary>
/// 각성 스킬 런타임 계약. 원 액티브를 대체(또는 강화)하여 진화.
/// BT12-Dev v1 §2-1 정합.
/// </summary>
public interface IAwakeningSkill : ISkillRuntime
{
/// <summary>진화 대상 원 액티브 데이터</summary>
ActiveSkillData OriginalActive { get; }
/// <summary>필요 패시브 후보 (1개 이상 보유로 조건 충족)</summary>
PassiveSkillData[] RequiredPassives { get; }
/// <summary>
/// 각성 발동 시 1회 호출. 원 액티브 슬롯 점유 유지하며 진화 형태로 대체.
/// 진화 패턴 4종 중 하나의 효과를 적용.
/// </summary>
void Awaken(PlayerSkillInventory inventory);
/// <summary>진화 패턴 (1 스케일업 · 2 새효과 · 3 다중 발동 · 4 광역 확산)</summary>
AwakeningPattern Pattern { get; }
}
/// <summary>
/// 각성 진화 패턴 (기획서 §4-2 4종). BT12-Dev v1 §2-1 정합.
/// </summary>
public enum AwakeningPattern
{
/// <summary>1. 대미지·범위·속도 대폭 증가</summary>
ScaleUp,
/// <summary>2. 새 효과 추가 (기존 유지 + 부가)</summary>
AddEffect,
/// <summary>3. 발동 수 2배+</summary>
MultiFire,
/// <summary>4. 화면 전체 확산</summary>
GlobalSpread
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f4e6fa02aa3ae043852be5a72495cf8

View File

@ -0,0 +1,47 @@
namespace EerieVillage.Skills
{
/// <summary>
/// 패시브 스킬 런타임 계약. 장착 즉시 상시 적용 + 일부는 조건부 트리거.
/// BT12-Dev v1 §2-1 정합.
/// </summary>
public interface IPassiveSkill : ISkillRuntime
{
/// <summary>상시 적용 여부 (true = 장착 즉시 효과 · false = 조건부)</summary>
bool IsAlwaysOn { get; }
/// <summary>
/// 장착·해제·Lv 업 시 인벤토리의 PlayerStats에 보정값 반영.
/// 스탯 상승형(P-A)·주기 단축(P-B)·생존 강화(P-C)·자원 확장(P-E)은 모두 이 경로.
/// </summary>
void ApplyModifier(PlayerStats stats);
/// <summary>해제 시 보정값 원복</summary>
void RemoveModifier(PlayerStats stats);
/// <summary>
/// 조건부 패시브(P11 호신부·P16 단전수련 등) 전용.
/// OnPlayerDamaged·OnEnemyKilled·OnTimer 이벤트 구독 후 조건 충족 시 효과 발동.
/// </summary>
void OnTrigger(PassiveTriggerContext ctx);
}
/// <summary>패시브 조건부 트리거 컨텍스트. BT12-Dev v1 §2-1 정합.</summary>
public struct PassiveTriggerContext
{
public PassiveTriggerKind Kind;
/// <summary>OnPlayerDamaged 전용</summary>
public float DamageTaken;
/// <summary>OnEnemyKilled 전용 (누적)</summary>
public int KillCount;
/// <summary>OnTimer 전용</summary>
public float TimeElapsed;
}
/// <summary>패시브 트리거 종류. BT12-Dev v1 §2-1 정합.</summary>
public enum PassiveTriggerKind
{
OnPlayerDamaged,
OnEnemyKilled,
OnTimer
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4229a01f493ce9f468a65556dff9d273

View File

@ -0,0 +1,27 @@
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// 모든 스킬 런타임의 공통 계약.
/// PlayerSkillInventory가 보유 스킬을 이 인터페이스 배열로 관리.
/// BT12-Dev v1 §2-1 정합.
/// </summary>
public interface ISkillRuntime
{
/// <summary>참조하는 정적 데이터 (ScriptableObject)</summary>
SkillDataAsset Data { get; }
/// <summary>현재 스택 레벨 (1~5). 5 도달 시 각성 조건 1 충족</summary>
int StackLevel { get; }
/// <summary>스킬 장착·Lv 업 시 1회 호출 (런타임 초기화)</summary>
void OnEquip(PlayerSkillInventory inventory);
/// <summary>스킬 해제 시 1회 호출 (각성 대체 등)</summary>
void OnUnequip();
/// <summary>동일 카드 재픽으로 Lv N→N+1 업그레이드</summary>
void Upgrade();
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44888b984c50f9f4d848b4d892c20be2

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 32ceb7645efbb5d489a2ab18dce6043a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,140 @@
using UnityEngine;
using Platformer.Core;
namespace EerieVillage.Skills
{
/// <summary>
/// 액티브 스킬 런타임 구현체. ActiveSkillData.Category 기준 단일 클래스.
/// BT12-Dev v1 §4-2 정합.
/// - OnTime: Tick()에서 쿨다운 감산 → Fire()
/// - OnHit·OnKill: PlayerSkillInventory 이벤트 핸들러에서 Fire() 직접 호출
/// - F(SpecialJudge): Fire() 내 확률 판정 선행
/// Phase 2-B에서 SkillFireEvent.Execute에 카테고리별 실 발동기 연결 예정.
/// </summary>
public class ActiveSkillRuntime : IActiveSkill
{
// --- ISkillRuntime ---
public SkillDataAsset Data => ActiveData;
public int StackLevel { get; private set; } = 1;
// --- IActiveSkill ---
public float BaseCooldown => ActiveData.BaseCooldown;
/// <summary>
/// 실제 쿨다운 = BaseCooldown * CooldownMultiplier(P06) / StackLevelFactor.
/// 하드캡 0.5s (balance/01 v0.2 §9).
/// </summary>
public float EffectiveCooldown =>
Mathf.Max(
BaseCooldown * _inventory.Stats.CooldownMultiplier / StackLevelFactor(StackLevel),
0.5f
);
public float CooldownRemaining { get; private set; } = 0f;
public ActiveTrigger Trigger => ActiveData.Trigger;
// --- 내부 ---
public ActiveSkillData ActiveData { get; private set; }
private PlayerSkillInventory _inventory;
public ActiveSkillRuntime(ActiveSkillData data)
{
ActiveData = data;
}
public void OnEquip(PlayerSkillInventory inventory)
{
_inventory = inventory;
CooldownRemaining = ActiveData.BaseCooldown; // 첫 발동은 주기 후
}
public void OnUnequip()
{
// OnHit·OnKill 이벤트 구독 해제 — Phase 2-D 인벤토리 통합 시 구현
}
public void Upgrade()
{
if (StackLevel < ActiveData.maxLevel && StackLevel < 5)
StackLevel++;
// Lv.5 도달 시 각성 조건 1 충족 — Phase 2-D AwakeningManager 연결 예정
}
/// <summary>
/// PlayerSkillInventory.Update가 매 프레임 호출.
/// OnTime 트리거만 처리 (OnHit·OnKill은 인벤토리 이벤트 핸들러에서 Fire() 직접 호출).
/// </summary>
public void Tick(float deltaTime)
{
if (Trigger != ActiveTrigger.OnTime) return;
CooldownRemaining -= deltaTime;
if (CooldownRemaining <= 0f)
{
Fire();
// 누적 오버플로 방지 (BT7-Dev PlayerAttackTicker와 동일 패턴)
CooldownRemaining += EffectiveCooldown;
CooldownRemaining = Mathf.Max(CooldownRemaining, 0f);
}
}
/// <summary>
/// 스킬 효과 Dispatch. SkillFireEvent를 Simulation에 Schedule.
/// F(SpecialJudge) 카테고리는 확률 판정 선행.
/// Phase 2-B에서 SkillFireEvent.Execute에 실 발동기 연결.
/// </summary>
public void Fire()
{
// F 카테고리 확률 판정
if (ActiveData.Category == ActiveCategory.SpecialJudge)
{
if (Random.value > ActiveData.FireProbability) return;
}
var ev = Simulation.Schedule<SkillFireEvent>();
ev.Runtime = this;
ev.Inventory = _inventory;
}
/// <summary>
/// StackLevel에 따른 대미지 팩터 (balance/01 v0.2 §3).
/// Lv.1=1.0 · Lv.2=1.2 · Lv.3=1.4 · Lv.4=1.6 · Lv.5=2.0
/// </summary>
public static float StackLevelFactor(int lv)
{
return lv switch
{
1 => 1.0f,
2 => 1.2f,
3 => 1.4f,
4 => 1.6f,
5 => 2.0f,
_ => 1.0f
};
}
/// <summary>
/// 유효 대미지 산출 (balance/01 v0.2 §3 대미지 공식).
/// effectiveDamage = BaseDamage * DamageMultiplier * StackLevelFactor * AttributeMultiplier
/// </summary>
public int CalculateEffectiveDamage()
{
if (_inventory == null) return ActiveData.BaseDamage;
var stats = _inventory.Stats;
float attrMult = 1.0f;
if (ActiveData.AttributeTags != AttributeTag.None &&
stats.AttributeMultiplier.TryGetValue(ActiveData.AttributeTags, out float v))
{
attrMult = v;
}
return Mathf.RoundToInt(
ActiveData.BaseDamage *
stats.DamageMultiplier *
StackLevelFactor(StackLevel) *
attrMult
);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e5a27b9347cd9e42944c4cef0f378d3

View File

@ -0,0 +1,188 @@
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;
// 장착 슬롯 (인덱스 = 슬롯 번호)
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;
void Awake()
{
Stats = new PlayerStats();
PlayerStats.Current = Stats;
_health = GetComponent<Health>();
}
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;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a17b9e800711bc49b02ea17cb0f459c

View File

@ -0,0 +1,64 @@
using System.Collections.Generic;
namespace EerieVillage.Skills
{
/// <summary>
/// 통합 플레이어 스탯. 패시브 보정이 모두 이 객체에 누적.
/// ActiveSkillRuntime의 EffectiveCooldown·EffectiveDamage 산출 입력.
/// BT12-Dev v1 §2-3 정합.
/// </summary>
public class PlayerStats
{
/// <summary>전역 싱글톤 참조 (PlayerSkillInventory가 갱신). Phase 2-D 정식 통합 시 DI 전환 예정.</summary>
public static PlayerStats Current = new PlayerStats();
/// <summary>대미지 배율 — P01 봉황격·P02~P05 속성 강화</summary>
public float DamageMultiplier = 1.0f;
/// <summary>쿨다운 배율 — P06 연사술 (0.8 = 20% 단축). EffectiveCooldown = BaseCooldown * CooldownMultiplier</summary>
public float CooldownMultiplier = 1.0f;
/// <summary>광역 배율 — P07 광역확장</summary>
public float AreaMultiplier = 1.0f;
/// <summary>추가 투사체 수 — P08 투사체증폭</summary>
public int ExtraProjectiles = 0;
/// <summary>크리티컬 확률 — P09</summary>
public float CritChance = 0f;
/// <summary>크리티컬 대미지 배율 — P10</summary>
public float CritDamage = 1.5f;
/// <summary>추가 최대 하트 수 — P12·P13</summary>
public int ExtraMaxHearts = 0;
/// <summary>피해 감소율 — P14 부적방패</summary>
public float DamageReduction = 0f;
/// <summary>회피 확률 — P15 회피술</summary>
public float EvasionChance = 0f;
/// <summary>i-frame 연장 시간 (초) — P15</summary>
public float IFrameExtend = 0f;
/// <summary>이동속도 배율 — P18 질풍보</summary>
public float MoveSpeedMultiplier = 1.0f;
/// <summary>경험치 배율 — P19 선견지명</summary>
public float XPMultiplier = 1.0f;
/// <summary>보물 발견 보너스 — P20 재물복</summary>
public float TreasureFindBonus = 0f;
/// <summary>속성별 대미지 배율 (AttributeTag → float)</summary>
public Dictionary<AttributeTag, float> AttributeMultiplier = new Dictionary<AttributeTag, float>
{
{ AttributeTag.Physical, 1.0f },
{ AttributeTag.Fire, 1.0f },
{ AttributeTag.Frost, 1.0f },
{ AttributeTag.Lightning, 1.0f },
{ AttributeTag.Dark, 1.0f }
};
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 62d37c6ad2d082741b239d566491f2a0

View File

@ -0,0 +1,119 @@
using System.Collections.Generic;
using UnityEngine;
namespace EerieVillage.Skills
{
/// <summary>
/// SkillDataAsset 캐시 로드 + ISkillRuntime 인스턴스 생성 팩토리.
/// BT12-Dev v1 §3-3 정합.
/// - Resolve(cardId): Resources/Skills/ 에서 CardId 기준 조회
/// - Create(data): 타입 분기 후 런타임 인스턴스 반환
/// Phase 2-C에서 .asset 60종 추가 시 자동 적재.
/// </summary>
public static class SkillRuntimeFactory
{
private static Dictionary<string, SkillDataAsset> _cache;
/// <summary>
/// Resources/Skills/ 하위 SkillDataAsset 전체 캐시 적재.
/// 최초 Resolve 호출 시 자동 실행.
/// Phase 2-C .asset 추가 전까지 _cache.Count == 0 정상 상태.
/// </summary>
public static void EnsureLoaded()
{
if (_cache != null) return;
_cache = new Dictionary<string, SkillDataAsset>();
var assets = Resources.LoadAll<SkillDataAsset>("Skills");
if (assets == null) return;
foreach (var a in assets)
{
if (a != null && !string.IsNullOrEmpty(a.CardId) && !_cache.ContainsKey(a.CardId))
{
_cache[a.CardId] = a;
}
}
Debug.Log($"[SkillRuntimeFactory] SkillDataAsset {_cache.Count}종 캐시 적재 완료.");
}
/// <summary>
/// cardId로 SkillDataAsset 조회. 없으면 null 반환.
/// </summary>
public static SkillDataAsset Resolve(string cardId)
{
EnsureLoaded();
if (string.IsNullOrEmpty(cardId)) return null;
return _cache.TryGetValue(cardId, out var data) ? data : null;
}
/// <summary>
/// SkillDataAsset 타입 분기 후 ISkillRuntime 인스턴스 생성.
/// BT12-Dev v1 §3-3 Factory 패턴.
/// </summary>
public static ISkillRuntime Create(SkillDataAsset data)
{
if (data == null) return null;
return data switch
{
ActiveSkillData a => new ActiveSkillRuntime(a),
PassiveSkillData p => new PassiveSkillRuntimeStub(p),
AwakeningSkillData w => new AwakeningSkillRuntimeStub(w),
_ => null
};
}
/// <summary>캐시 강제 리셋 (에디터 툴·테스트용)</summary>
public static void InvalidateCache()
{
_cache = null;
}
}
// -------------------------------------------------------------------------
// Phase 2 범위 외 stub 구현체
// PassiveSkillRuntime·AwakeningSkillRuntime은 Phase 2-C 이후 정식 구현 예정.
// -------------------------------------------------------------------------
/// <summary>
/// 패시브 스킬 런타임 stub. Phase 2-C에서 정식 구현으로 교체 예정.
/// </summary>
internal class PassiveSkillRuntimeStub : IPassiveSkill
{
private readonly PassiveSkillData _data;
public PassiveSkillRuntimeStub(PassiveSkillData data) { _data = data; }
public SkillDataAsset Data => _data;
public int StackLevel => 1;
public bool IsAlwaysOn => _data.IsAlwaysOn;
public void OnEquip(PlayerSkillInventory inventory) { }
public void OnUnequip() { }
public void Upgrade() { }
public void ApplyModifier(PlayerStats stats) { }
public void RemoveModifier(PlayerStats stats) { }
public void OnTrigger(PassiveTriggerContext ctx) { }
}
/// <summary>
/// 각성 스킬 런타임 stub. Phase 2-E에서 정식 구현으로 교체 예정.
/// </summary>
internal class AwakeningSkillRuntimeStub : IAwakeningSkill
{
private readonly AwakeningSkillData _data;
public AwakeningSkillRuntimeStub(AwakeningSkillData data) { _data = data; }
public SkillDataAsset Data => _data;
public int StackLevel => 1;
public ActiveSkillData OriginalActive => _data.OriginalActive;
public PassiveSkillData[] RequiredPassives => _data.RequiredPassives;
public AwakeningPattern Pattern => _data.Pattern;
public void OnEquip(PlayerSkillInventory inventory) { }
public void OnUnequip() { }
public void Upgrade() { }
public void Awaken(PlayerSkillInventory inventory) { }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ba6e221878bb29d48a49a0504036c2b8