From 2f2790ce57a4cc5973ae685197646892811edacc Mon Sep 17 00:00:00 2001 From: swrring Date: Sat, 9 May 2026 19:00:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(BT12-Dev=20Phase=202-B):=20=ED=88=AC?= =?UTF-8?q?=EC=82=AC=EC=B2=B4=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=206?= =?UTF-8?q?=EC=A2=85=20=ED=9A=A8=EA=B3=BC=20=EB=B0=9C=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../Scripts/Skills/Effectors/DebuffStack.cs | 62 ++++++++ .../Skills/Effectors/EnemyStateComponents.cs | 145 ++++++++++++++++++ .../Skills/Effectors/HomingProjectile.cs | 63 ++++++++ Assets/Scripts/Skills/Effectors/IEffector.cs | 12 ++ Assets/Scripts/Skills/Effectors/Projectile.cs | 79 ++++++++++ .../Skills/Effectors/ProjectileSpawner.cs | 86 +++++++++++ .../Scripts/Skills/Effectors/StatusApplier.cs | 84 ++++++++++ .../Scripts/Skills/Events/SkillFireEvent.cs | 43 +++--- 8 files changed, 554 insertions(+), 20 deletions(-) create mode 100644 Assets/Scripts/Skills/Effectors/DebuffStack.cs create mode 100644 Assets/Scripts/Skills/Effectors/EnemyStateComponents.cs create mode 100644 Assets/Scripts/Skills/Effectors/HomingProjectile.cs create mode 100644 Assets/Scripts/Skills/Effectors/IEffector.cs create mode 100644 Assets/Scripts/Skills/Effectors/Projectile.cs create mode 100644 Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs create mode 100644 Assets/Scripts/Skills/Effectors/StatusApplier.cs 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()