diff --git a/Assets/Scripts/Skills/Effectors/DebuffStack.cs b/Assets/Scripts/Skills/Effectors/DebuffStack.cs
new file mode 100644
index 0000000..794cf63
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/DebuffStack.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// A08 저주의 화살 — 저주 스택 관리 정적 레지스트리.
+ /// N 스택 도달 시 누적 폭발 대미지 처리.
+ /// BT12-Dev Phase 2-B §4-6.
+ ///
+ /// 주의: 씬 전환·EnemyController 파괴 시 Clear(enemy)를 외부에서 호출해야
+ /// Dictionary 누수를 방지할 수 있다 (Phase 2-C GC 개선 대상).
+ ///
+ public static class DebuffStack
+ {
+ private static readonly Dictionary _stacks =
+ new Dictionary();
+
+ ///
+ /// 스택 1 추가. limit 도달 시 폭발 대미지 처리 후 스택 초기화.
+ /// explosionDamage = ActiveSkillData.BaseDamage 기준.
+ ///
+ public static void AddStack(EnemyController enemy, int limit, int explosionDamage)
+ {
+ if (enemy == null) return;
+
+ if (!_stacks.ContainsKey(enemy))
+ _stacks[enemy] = 0;
+
+ _stacks[enemy]++;
+
+ if (_stacks[enemy] >= limit)
+ {
+ // N스택 폭발 대미지 — 스택 수 × baseDamage × 2
+ int total = explosionDamage * _stacks[enemy] * 2;
+ var health = enemy.GetComponent();
+ if (health != null && health.IsAlive)
+ {
+ health.Decrement(total);
+ }
+ _stacks[enemy] = 0;
+ }
+ }
+
+ ///
+ /// 적 사망 또는 씬 전환 시 스택 레코드 제거.
+ ///
+ public static void Clear(EnemyController enemy)
+ {
+ if (enemy != null)
+ _stacks.Remove(enemy);
+ }
+
+ /// 현재 스택 수 조회 (디버그용).
+ public static int GetStack(EnemyController enemy)
+ {
+ if (enemy == null) return 0;
+ return _stacks.TryGetValue(enemy, out int v) ? v : 0;
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/EnemyStateComponents.cs b/Assets/Scripts/Skills/Effectors/EnemyStateComponents.cs
new file mode 100644
index 0000000..4ef8651
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/EnemyStateComponents.cs
@@ -0,0 +1,145 @@
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 적 상태 컴포넌트 통합 파일.
+ /// DoT·Stun·Slow 세 MonoBehaviour를 단일 파일에 정의 (C14 토큰·파일 최소화).
+ /// BT12-Dev Phase 2-B §4-7.
+ ///
+
+ // -------------------------------------------------------------------------
+ // EnemyDoTState — 화염 지속 대미지
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 적에게 주기적 DoT(Damage over Time)을 가하는 컴포넌트.
+ /// StatusApplier.ApplyDoT 에서 AddComponent 또는 기존 컴포넌트 재사용.
+ /// 동일 적에 복수 AddDoT 호출 시 마지막 호출로 덮어씌운다 (스택 비허용 — Phase 2 범위).
+ ///
+ public class EnemyDoTState : MonoBehaviour
+ {
+ private int _damagePerTick;
+ private float _duration;
+ private float _interval;
+ private float _elapsed;
+ private float _tickElapsed;
+
+ public void AddDoT(int dmg, float duration, float interval)
+ {
+ _damagePerTick = dmg;
+ _duration = duration;
+ _interval = interval;
+ _elapsed = 0f;
+ _tickElapsed = 0f;
+ }
+
+ private void Update()
+ {
+ if (_duration <= 0f) return;
+
+ _elapsed += Time.deltaTime;
+ _tickElapsed += Time.deltaTime;
+
+ if (_tickElapsed >= _interval)
+ {
+ _tickElapsed = 0f;
+ var hp = GetComponent();
+ if (hp != null && hp.IsAlive)
+ hp.Decrement(_damagePerTick);
+ }
+
+ if (_elapsed >= _duration)
+ Destroy(this);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // EnemyStunState — 기절 (EnemyController.enabled = false)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 적을 기절시키는 컴포넌트.
+ /// EnemyController.enabled = false 로 patrol·Update 를 일시 정지한다.
+ /// 지속 시간 만료 시 EnemyController.enabled = true 복원.
+ ///
+ public class EnemyStunState : MonoBehaviour
+ {
+ private float _duration;
+ private float _elapsed;
+ private EnemyController _enemy;
+
+ private void Awake()
+ {
+ _enemy = GetComponent();
+ }
+
+ public void ApplyStun(float duration)
+ {
+ _duration = duration;
+ _elapsed = 0f;
+ if (_enemy != null)
+ _enemy.enabled = false;
+ }
+
+ private void Update()
+ {
+ if (_duration <= 0f) return;
+
+ _elapsed += Time.deltaTime;
+ if (_elapsed >= _duration)
+ {
+ if (_enemy != null)
+ _enemy.enabled = true;
+ Destroy(this);
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // EnemySlowState — 감속 (AnimationController.maxSpeed 축소)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 적을 감속시키는 컴포넌트.
+ /// AnimationController.maxSpeed 를 multiplier 배율로 축소하고, 지속 시간 만료 시 원복.
+ /// EnemyController 는 AnimationController 를 RequireComponent 하므로 항상 존재.
+ ///
+ public class EnemySlowState : MonoBehaviour
+ {
+ private float _duration;
+ private float _multiplier;
+ private float _elapsed;
+ private float _origMaxSpeed;
+ private AnimationController _anim;
+
+ private void Awake()
+ {
+ _anim = GetComponent();
+ _origMaxSpeed = _anim != null ? _anim.maxSpeed : 0f;
+ }
+
+ public void ApplySlow(float duration, float multiplier)
+ {
+ _duration = duration;
+ _multiplier = multiplier;
+ _elapsed = 0f;
+ if (_anim != null)
+ _anim.maxSpeed = _origMaxSpeed * _multiplier;
+ }
+
+ private void Update()
+ {
+ if (_duration <= 0f) return;
+
+ _elapsed += Time.deltaTime;
+ if (_elapsed >= _duration)
+ {
+ if (_anim != null)
+ _anim.maxSpeed = _origMaxSpeed;
+ Destroy(this);
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/HomingProjectile.cs b/Assets/Scripts/Skills/Effectors/HomingProjectile.cs
new file mode 100644
index 0000000..1aae0ab
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/HomingProjectile.cs
@@ -0,0 +1,63 @@
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 유도 투사체. A15 추적 화염구 전용.
+ /// Projectile 파생 — Update를 FixedUpdate 기반 방향 보정으로 확장.
+ /// BT12-Dev Phase 2-B §4-3.
+ ///
+ public class HomingProjectile : Projectile
+ {
+ private Transform _target;
+ private float _homingStrength = 5f;
+
+ public override void Initialize(ActiveSkillRuntime runtime, PlayerSkillInventory inventory, Vector2 direction)
+ {
+ base.Initialize(runtime, inventory, direction);
+ _target = FindNearestEnemy();
+ }
+
+ protected override void Update()
+ {
+ // 타겟 갱신 (사망·삭제 시 재탐색)
+ if (_target == null || !_target.gameObject.activeInHierarchy)
+ {
+ _target = FindNearestEnemy();
+ }
+
+ if (_target != null)
+ {
+ Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
+ _direction = Vector2.Lerp(_direction, toTarget, _homingStrength * Time.deltaTime).normalized;
+ }
+
+ // 부모 Update — 실제 이동
+ base.Update();
+ }
+
+ private Transform FindNearestEnemy()
+ {
+ var enemies = Object.FindObjectsByType(FindObjectsSortMode.None);
+ Transform nearest = null;
+ float minDist = float.MaxValue;
+
+ foreach (var e in enemies)
+ {
+ if (e == null) continue;
+ var hp = e.GetComponent();
+ if (hp == null || !hp.IsAlive) continue;
+
+ float d = Vector2.Distance(transform.position, e.transform.position);
+ if (d < minDist)
+ {
+ minDist = d;
+ nearest = e.transform;
+ }
+ }
+
+ return nearest;
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/IEffector.cs b/Assets/Scripts/Skills/Effectors/IEffector.cs
new file mode 100644
index 0000000..9a51bc0
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/IEffector.cs
@@ -0,0 +1,12 @@
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 모든 효과 발동기 공통 인터페이스.
+ /// SkillFireEvent.Execute 에서 카테고리 분기 후 본 인터페이스를 통해 호출한다.
+ /// BT12-Dev Phase 2-B §4-1.
+ ///
+ public interface IEffector
+ {
+ void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory);
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/Projectile.cs b/Assets/Scripts/Skills/Effectors/Projectile.cs
new file mode 100644
index 0000000..7868e81
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/Projectile.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 투사체 기본 컴포넌트. Line 궤적 직선 이동·단일 적 타격 후 소멸.
+ /// BT12-Dev Phase 2-B §4-2.
+ /// 파생: (A15 추적 화염구).
+ ///
+ public class Projectile : MonoBehaviour
+ {
+ protected ActiveSkillData _data;
+ protected ActiveSkillRuntime _runtime;
+ protected PlayerSkillInventory _inventory;
+ protected Vector2 _direction;
+ protected float _speed = 12f;
+ protected float _lifetime = 3f;
+
+ // 동일 투사체로 동일 Collider 중복 타격 방지
+ protected readonly HashSet _hitTargets = new HashSet();
+
+ ///
+ /// ProjectileSpawner.Trigger 에서 Instantiate 직후 호출.
+ ///
+ public virtual void Initialize(ActiveSkillRuntime runtime, PlayerSkillInventory inventory, Vector2 direction)
+ {
+ _runtime = runtime;
+ _data = runtime.ActiveData;
+ _inventory = inventory;
+ _direction = direction.normalized;
+ _hitTargets.Clear();
+
+ // Phase 2-B: 풀링 미도입 — Invoke 기반 자동 소멸
+ Invoke(nameof(SelfDestruct), _lifetime);
+ }
+
+ protected virtual void Update()
+ {
+ transform.position += (Vector3)(_direction * _speed * Time.deltaTime);
+ }
+
+ protected virtual void OnTriggerEnter2D(Collider2D other)
+ {
+ if (_hitTargets.Contains(other)) return;
+
+ // Enemy 레이어 한정
+ if (other.gameObject.layer != LayerMask.NameToLayer("Enemy")) return;
+
+ var health = other.GetComponent();
+ if (health == null || !health.IsAlive) return;
+
+ _hitTargets.Add(other);
+
+ // 유효 대미지 산출 (balance/01 v0.2 §3 공식 — ActiveSkillRuntime.CalculateEffectiveDamage())
+ int damage = _runtime.CalculateEffectiveDamage();
+
+ // 피해 적용
+ health.Decrement(damage);
+
+ // 부가 효과 (DoT·Stun·Slow·DebuffStack) — StatusApplier 위임
+ var enemy = other.GetComponent();
+ if (enemy != null)
+ {
+ StatusApplier.Apply(_data, enemy);
+ }
+
+ // 단일 적 타격 후 소멸 (관통 미지원 — Phase 2 범위 내)
+ SelfDestruct();
+ }
+
+ protected void SelfDestruct()
+ {
+ CancelInvoke(nameof(SelfDestruct));
+ Destroy(gameObject);
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs b/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs
new file mode 100644
index 0000000..c5523e1
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs
@@ -0,0 +1,86 @@
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 투사체 생성기. IEffector 구현체.
+ /// SkillFireEvent.Execute 에서 ActiveCategory.Projectile 분기 시 호출.
+ /// BT12-Dev Phase 2-B §4-4.
+ ///
+ /// 다중 발사: PlayerStats.ExtraProjectiles 반영 (P08 투사체증폭).
+ /// 궤적 분기: ActiveSkillData.Trajectory — Line → Projectile, Homing → HomingProjectile.
+ ///
+ public class ProjectileSpawner : IEffector
+ {
+ public void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory)
+ {
+ var data = runtime.ActiveData;
+
+ // 플레이어 위치·방향 취득
+ Transform playerTransform = inventory.transform;
+ Vector2 spawnPos = playerTransform.position;
+
+ // PlayerController.Facing 참조
+ Vector2 facing = Vector2.right;
+ var pc = inventory.GetComponent();
+ if (pc != null) facing = pc.Facing;
+
+ // 프리팹 로드
+ GameObject prefab = LoadProjectilePrefab(data);
+ if (prefab == null)
+ {
+ Debug.LogWarning($"[ProjectileSpawner] 투사체 프리팹 로드 실패 (data.id={data.CardId})");
+ return;
+ }
+
+ // 다중 발사 수 (기본 1 + ExtraProjectiles)
+ int count = 1 + Mathf.Max(0, inventory.Stats.ExtraProjectiles);
+ float angleStep = count > 1 ? 10f : 0f;
+ float startAngle = -angleStep * (count - 1) * 0.5f;
+
+ for (int i = 0; i < count; i++)
+ {
+ float angle = startAngle + angleStep * i;
+ Vector2 dir = RotateVector(facing, angle);
+
+ var go = Object.Instantiate(prefab, (Vector3)spawnPos, Quaternion.identity);
+
+ Projectile proj;
+ if (data.Trajectory == ProjectileTrajectory.Homing)
+ proj = go.GetComponent() ?? go.AddComponent();
+ else
+ proj = go.GetComponent() ?? go.AddComponent();
+
+ proj.Initialize(runtime, inventory, dir);
+ }
+ }
+
+ ///
+ /// 투사체 프리팹 로드.
+ /// Phase 2-C 에서 data.projectilePrefab 필드를 추가하면 해당 경로 우선 사용.
+ /// 현재는 Resources/Skills/Projectiles/Default 폴백.
+ /// 폴백 실패 시 Collider2D 부착 빈 오브젝트를 반환한다.
+ ///
+ private static GameObject LoadProjectilePrefab(ActiveSkillData data)
+ {
+ var prefab = Resources.Load("Skills/Projectiles/Default");
+ if (prefab != null) return prefab;
+
+ // 폴백 — 빈 오브젝트 + CircleCollider2D (시각 없음, 판정만)
+ var go = new GameObject($"Projectile_{data.CardId}");
+ var col = go.AddComponent();
+ col.isTrigger = true;
+ col.radius = 0.2f;
+ return go;
+ }
+
+ private static Vector2 RotateVector(Vector2 v, float degrees)
+ {
+ float rad = degrees * Mathf.Deg2Rad;
+ float cos = Mathf.Cos(rad);
+ float sin = Mathf.Sin(rad);
+ return new Vector2(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Effectors/StatusApplier.cs b/Assets/Scripts/Skills/Effectors/StatusApplier.cs
new file mode 100644
index 0000000..66ce792
--- /dev/null
+++ b/Assets/Scripts/Skills/Effectors/StatusApplier.cs
@@ -0,0 +1,84 @@
+using UnityEngine;
+using Platformer.Mechanics;
+
+namespace EerieVillage.Skills.Effectors
+{
+ ///
+ /// 상태 효과 일괄 적용기.
+ /// Projectile.OnTriggerEnter2D 등 타격 판정 후 ActiveSkillData 필드를 읽어
+ /// DoT·Stun·Slow·Knockback·DebuffStack 을 EnemyController 에 적용한다.
+ /// BT12-Dev Phase 2-B §4-5.
+ ///
+ public static class StatusApplier
+ {
+ public static void Apply(ActiveSkillData data, EnemyController enemy)
+ {
+ if (data == null || enemy == null) return;
+
+ // DoT (화염·독 등) — DotDuration > 0 && DotInterval > 0
+ if (data.DotDuration > 0f && data.DotInterval > 0f)
+ {
+ ApplyDoT(enemy, data.BaseDamage, data.DotDuration, data.DotInterval);
+ }
+
+ // 기절 (스턴)
+ if (data.StunDuration > 0f)
+ {
+ ApplyStun(enemy, data.StunDuration);
+ }
+
+ // 감속 (슬로우)
+ if (data.SlowDuration > 0f && data.SlowMultiplier < 1.0f)
+ {
+ ApplySlow(enemy, data.SlowDuration, data.SlowMultiplier);
+ }
+
+ // 넉백
+ if (data.KnockbackForce > 0f)
+ {
+ ApplyKnockback(enemy, data.KnockbackForce);
+ }
+
+ // 저주 스택 (A08 저주의 화살)
+ if (data.DebuffStackLimit > 0)
+ {
+ DebuffStack.AddStack(enemy, data.DebuffStackLimit, data.BaseDamage);
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // 내부 헬퍼
+ // -------------------------------------------------------------------
+
+ private static void ApplyDoT(EnemyController enemy, int damagePerTick, float duration, float interval)
+ {
+ var existing = enemy.GetComponent();
+ if (existing == null) existing = enemy.gameObject.AddComponent();
+ existing.AddDoT(damagePerTick, duration, interval);
+ }
+
+ private static void ApplyStun(EnemyController enemy, float duration)
+ {
+ var existing = enemy.GetComponent();
+ if (existing == null) existing = enemy.gameObject.AddComponent();
+ existing.ApplyStun(duration);
+ }
+
+ private static void ApplySlow(EnemyController enemy, float duration, float multiplier)
+ {
+ var existing = enemy.GetComponent();
+ if (existing == null) existing = enemy.gameObject.AddComponent();
+ existing.ApplySlow(duration, multiplier);
+ }
+
+ private static void ApplyKnockback(EnemyController enemy, float force)
+ {
+ var rb = enemy.GetComponent();
+ if (rb != null)
+ {
+ // facing 반대 방향 넉백 — 단순 +x 방향 (Phase 2 범위, Phase 2-C에서 방향 정합 예정)
+ rb.AddForce(Vector2.right * force, ForceMode2D.Impulse);
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Skills/Events/SkillFireEvent.cs b/Assets/Scripts/Skills/Events/SkillFireEvent.cs
index 4db1477..1ef9ff7 100644
--- a/Assets/Scripts/Skills/Events/SkillFireEvent.cs
+++ b/Assets/Scripts/Skills/Events/SkillFireEvent.cs
@@ -1,4 +1,5 @@
using Platformer.Core;
+using EerieVillage.Skills.Effectors;
namespace EerieVillage.Skills
{
@@ -7,8 +8,9 @@ namespace EerieVillage.Skills
/// ActiveSkillRuntime.Fire()에서 Simulation.Schedule<SkillFireEvent>() 호출.
/// BT12-Dev v1 §3-4 정합.
///
- /// Phase 2-A: Execute = stub (카테고리 분기 구조만 명시).
- /// Phase 2-B: 카테고리별 실 발동기 (ProjectileSpawner·AttackHitbox 등) 연결 예정.
+ /// Phase 2-A: Execute = stub.
+ /// Phase 2-B: ActiveCategory.Projectile → ProjectileSpawner 연결 완료.
+ /// 나머지 카테고리 (MeleeArea·PlacementPersistent·Minion·Debuff·SpecialJudge) = Phase 2-C~ 예정.
///
public class SkillFireEvent : Simulation.Event
{
@@ -22,24 +24,25 @@ namespace EerieVillage.Skills
{
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;
- // }
+ var data = Runtime.ActiveData;
+ if (data == null) return;
+
+ if (Inventory == null) return;
+
+ // 카테고리 분기 → IEffector 호출
+ IEffector effector = null;
+ switch (data.Category)
+ {
+ case ActiveCategory.Projectile:
+ effector = new ProjectileSpawner();
+ break;
+
+ // Phase 2-C~ 예정: MeleeArea·PlacementPersistent·Minion·Debuff·SpecialJudge
+ default:
+ return;
+ }
+
+ effector?.Trigger(Runtime, Inventory);
}
internal override void Cleanup()