feat(BT12-Dev Phase 2-B): 투사체 카테고리 6종 효과 발동기 구현

- Effectors/IEffector.cs: 효과 발동기 공통 인터페이스
- Effectors/Projectile.cs: Line 직선 투사체 (단일 적 타격 후 소멸)
- Effectors/HomingProjectile.cs: Homing 유도 투사체 A15 (FindNearestEnemy)
- Effectors/ProjectileSpawner.cs: IEffector 구현 — 다중 발사·궤적 분기
- Effectors/StatusApplier.cs: DoT·Stun·Slow·Knockback·DebuffStack 통합 적용기
- Effectors/DebuffStack.cs: A08 저주 스택 N회 폭발 레지스트리
- Effectors/EnemyStateComponents.cs: DoT·Stun·Slow MonoBehaviour 통합
- Events/SkillFireEvent.cs: Execute stub → ActiveCategory.Projectile 분기 정식 연결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-05-09 19:00:27 +09:00
parent 87710bac58
commit 2f2790ce57
8 changed files with 554 additions and 20 deletions

View File

@ -0,0 +1,62 @@
using System.Collections.Generic;
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// A08 저주의 화살 — 저주 스택 관리 정적 레지스트리.
/// N 스택 도달 시 누적 폭발 대미지 처리.
/// BT12-Dev Phase 2-B §4-6.
///
/// 주의: 씬 전환·EnemyController 파괴 시 Clear(enemy)를 외부에서 호출해야
/// Dictionary 누수를 방지할 수 있다 (Phase 2-C GC 개선 대상).
/// </summary>
public static class DebuffStack
{
private static readonly Dictionary<EnemyController, int> _stacks =
new Dictionary<EnemyController, int>();
/// <summary>
/// 스택 1 추가. limit 도달 시 폭발 대미지 처리 후 스택 초기화.
/// explosionDamage = ActiveSkillData.BaseDamage 기준.
/// </summary>
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<Health>();
if (health != null && health.IsAlive)
{
health.Decrement(total);
}
_stacks[enemy] = 0;
}
}
/// <summary>
/// 적 사망 또는 씬 전환 시 스택 레코드 제거.
/// </summary>
public static void Clear(EnemyController enemy)
{
if (enemy != null)
_stacks.Remove(enemy);
}
/// <summary>현재 스택 수 조회 (디버그용).</summary>
public static int GetStack(EnemyController enemy)
{
if (enemy == null) return 0;
return _stacks.TryGetValue(enemy, out int v) ? v : 0;
}
}
}

View File

@ -0,0 +1,145 @@
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 적 상태 컴포넌트 통합 파일.
/// DoT·Stun·Slow 세 MonoBehaviour를 단일 파일에 정의 (C14 토큰·파일 최소화).
/// BT12-Dev Phase 2-B §4-7.
/// </summary>
// -------------------------------------------------------------------------
// EnemyDoTState — 화염 지속 대미지
// -------------------------------------------------------------------------
/// <summary>
/// 적에게 주기적 DoT(Damage over Time)을 가하는 컴포넌트.
/// StatusApplier.ApplyDoT 에서 AddComponent 또는 기존 컴포넌트 재사용.
/// 동일 적에 복수 AddDoT 호출 시 마지막 호출로 덮어씌운다 (스택 비허용 — Phase 2 범위).
/// </summary>
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<Health>();
if (hp != null && hp.IsAlive)
hp.Decrement(_damagePerTick);
}
if (_elapsed >= _duration)
Destroy(this);
}
}
// -------------------------------------------------------------------------
// EnemyStunState — 기절 (EnemyController.enabled = false)
// -------------------------------------------------------------------------
/// <summary>
/// 적을 기절시키는 컴포넌트.
/// EnemyController.enabled = false 로 patrol·Update 를 일시 정지한다.
/// 지속 시간 만료 시 EnemyController.enabled = true 복원.
/// </summary>
public class EnemyStunState : MonoBehaviour
{
private float _duration;
private float _elapsed;
private EnemyController _enemy;
private void Awake()
{
_enemy = GetComponent<EnemyController>();
}
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 축소)
// -------------------------------------------------------------------------
/// <summary>
/// 적을 감속시키는 컴포넌트.
/// AnimationController.maxSpeed 를 multiplier 배율로 축소하고, 지속 시간 만료 시 원복.
/// EnemyController 는 AnimationController 를 RequireComponent 하므로 항상 존재.
/// </summary>
public class EnemySlowState : MonoBehaviour
{
private float _duration;
private float _multiplier;
private float _elapsed;
private float _origMaxSpeed;
private AnimationController _anim;
private void Awake()
{
_anim = GetComponent<AnimationController>();
_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);
}
}
}
}

View File

@ -0,0 +1,63 @@
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 유도 투사체. A15 추적 화염구 전용.
/// Projectile 파생 — Update를 FixedUpdate 기반 방향 보정으로 확장.
/// BT12-Dev Phase 2-B §4-3.
/// </summary>
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<EnemyController>(FindObjectsSortMode.None);
Transform nearest = null;
float minDist = float.MaxValue;
foreach (var e in enemies)
{
if (e == null) continue;
var hp = e.GetComponent<Health>();
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;
}
}
}

View File

@ -0,0 +1,12 @@
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 모든 효과 발동기 공통 인터페이스.
/// SkillFireEvent.Execute 에서 카테고리 분기 후 본 인터페이스를 통해 호출한다.
/// BT12-Dev Phase 2-B §4-1.
/// </summary>
public interface IEffector
{
void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory);
}
}

View File

@ -0,0 +1,79 @@
using System.Collections.Generic;
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 투사체 기본 컴포넌트. Line 궤적 직선 이동·단일 적 타격 후 소멸.
/// BT12-Dev Phase 2-B §4-2.
/// 파생: <see cref="HomingProjectile"/> (A15 추적 화염구).
/// </summary>
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<Collider2D> _hitTargets = new HashSet<Collider2D>();
/// <summary>
/// ProjectileSpawner.Trigger 에서 Instantiate 직후 호출.
/// </summary>
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<Health>();
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<EnemyController>();
if (enemy != null)
{
StatusApplier.Apply(_data, enemy);
}
// 단일 적 타격 후 소멸 (관통 미지원 — Phase 2 범위 내)
SelfDestruct();
}
protected void SelfDestruct()
{
CancelInvoke(nameof(SelfDestruct));
Destroy(gameObject);
}
}
}

View File

@ -0,0 +1,86 @@
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 투사체 생성기. IEffector 구현체.
/// SkillFireEvent.Execute 에서 ActiveCategory.Projectile 분기 시 호출.
/// BT12-Dev Phase 2-B §4-4.
///
/// 다중 발사: PlayerStats.ExtraProjectiles 반영 (P08 투사체증폭).
/// 궤적 분기: ActiveSkillData.Trajectory — Line → Projectile, Homing → HomingProjectile.
/// </summary>
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<PlayerController>();
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<HomingProjectile>() ?? go.AddComponent<HomingProjectile>();
else
proj = go.GetComponent<Projectile>() ?? go.AddComponent<Projectile>();
proj.Initialize(runtime, inventory, dir);
}
}
/// <summary>
/// 투사체 프리팹 로드.
/// Phase 2-C 에서 data.projectilePrefab 필드를 추가하면 해당 경로 우선 사용.
/// 현재는 Resources/Skills/Projectiles/Default 폴백.
/// 폴백 실패 시 Collider2D 부착 빈 오브젝트를 반환한다.
/// </summary>
private static GameObject LoadProjectilePrefab(ActiveSkillData data)
{
var prefab = Resources.Load<GameObject>("Skills/Projectiles/Default");
if (prefab != null) return prefab;
// 폴백 — 빈 오브젝트 + CircleCollider2D (시각 없음, 판정만)
var go = new GameObject($"Projectile_{data.CardId}");
var col = go.AddComponent<CircleCollider2D>();
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);
}
}
}

View File

@ -0,0 +1,84 @@
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Skills.Effectors
{
/// <summary>
/// 상태 효과 일괄 적용기.
/// Projectile.OnTriggerEnter2D 등 타격 판정 후 ActiveSkillData 필드를 읽어
/// DoT·Stun·Slow·Knockback·DebuffStack 을 EnemyController 에 적용한다.
/// BT12-Dev Phase 2-B §4-5.
/// </summary>
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<EnemyDoTState>();
if (existing == null) existing = enemy.gameObject.AddComponent<EnemyDoTState>();
existing.AddDoT(damagePerTick, duration, interval);
}
private static void ApplyStun(EnemyController enemy, float duration)
{
var existing = enemy.GetComponent<EnemyStunState>();
if (existing == null) existing = enemy.gameObject.AddComponent<EnemyStunState>();
existing.ApplyStun(duration);
}
private static void ApplySlow(EnemyController enemy, float duration, float multiplier)
{
var existing = enemy.GetComponent<EnemySlowState>();
if (existing == null) existing = enemy.gameObject.AddComponent<EnemySlowState>();
existing.ApplySlow(duration, multiplier);
}
private static void ApplyKnockback(EnemyController enemy, float force)
{
var rb = enemy.GetComponent<Rigidbody2D>();
if (rb != null)
{
// facing 반대 방향 넉백 — 단순 +x 방향 (Phase 2 범위, Phase 2-C에서 방향 정합 예정)
rb.AddForce(Vector2.right * force, ForceMode2D.Impulse);
}
}
}
}

View File

@ -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&lt;SkillFireEvent&gt;() 호출.
/// 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~ 예정.
/// </summary>
public class SkillFireEvent : Simulation.Event<SkillFireEvent>
{
@ -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()