From 171506e16df4ac3ad71e89cf28b66f62822b0d61 Mon Sep 17 00:00:00 2001 From: swrring Date: Mon, 18 May 2026 09:39:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(BT12-Dev-Clone):=20A10=20=EB=B6=84?= =?UTF-8?q?=EC=8B=A0=20=EC=8A=A4=ED=82=AC=204=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PD 명세 5항목 + PD 결정 4건 정합: - 위치: facing 반대 1유닛 (CloneInstance.SpawnOrReplace) - 외형: 반투명 alpha 0.5 (SpriteRenderer 복제) - 동작: 동일 스킬 미러링 (OnPlayerSkillFired hook + 0.5초 지연 큐) - 공격력: 50% 반감 (CalculateEffectiveDamage IsCloneFireActive 분기) - 타이밍: 0.5초 딜레이 (FIRE_DELAY_SECONDS) - BaseCooldown 25초·MinionLifetime 12초·facing 고정·무적(Collider 미부착) - Lv 업 메커니즘: 분신 수 X·지속시간+데미지 비율 ↑ (balance 후속) 신규 4 (CloneInstance·CloneEffector·A10_bunsin.asset·CloneSkillTests + .meta) 수정 10 (PlayerSkillInventory·ActiveSkillRuntime·SkillFireEvent·SkillRuntimeFactory·6 Effector) γ helper: PlayerSkillInventory.GetSpawnAnchor·GetSpawnFacing — 6 Effector 진입점 단일화 --- .../Resources/Skills/Active/A10_bunsin.asset | 70 ++++++ .../Skills/Active/A10_bunsin.asset.meta | 8 + .../Scripts/Skills/Effectors/CloneEffector.cs | 24 ++ .../Skills/Effectors/CloneEffector.cs.meta | 2 + .../Scripts/Skills/Effectors/CloneInstance.cs | 221 ++++++++++++++++++ .../Skills/Effectors/CloneInstance.cs.meta | 2 + .../Scripts/Skills/Effectors/LaserSpawner.cs | 8 +- .../Effectors/LightningStrikeSpawner.cs | 2 +- .../Skills/Effectors/MeleeAreaSpawner.cs | 8 +- .../Skills/Effectors/PoisonSwampSpawner.cs | 2 +- .../Skills/Effectors/ProjectileSpawner.cs | 10 +- .../Skills/Effectors/SpiritFireSpawner.cs | 4 +- .../Scripts/Skills/Events/SkillFireEvent.cs | 8 +- .../Skills/Runtime/ActiveSkillRuntime.cs | 17 +- .../Skills/Runtime/PlayerSkillInventory.cs | 40 ++++ .../Skills/Runtime/SkillRuntimeFactory.cs | 4 +- Assets/Tests/Editor/CloneSkillTests.cs | 123 ++++++++++ Assets/Tests/Editor/CloneSkillTests.cs.meta | 11 + 18 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 Assets/Resources/Skills/Active/A10_bunsin.asset create mode 100644 Assets/Resources/Skills/Active/A10_bunsin.asset.meta create mode 100644 Assets/Scripts/Skills/Effectors/CloneEffector.cs create mode 100644 Assets/Scripts/Skills/Effectors/CloneEffector.cs.meta create mode 100644 Assets/Scripts/Skills/Effectors/CloneInstance.cs create mode 100644 Assets/Scripts/Skills/Effectors/CloneInstance.cs.meta create mode 100644 Assets/Tests/Editor/CloneSkillTests.cs create mode 100644 Assets/Tests/Editor/CloneSkillTests.cs.meta diff --git a/Assets/Resources/Skills/Active/A10_bunsin.asset b/Assets/Resources/Skills/Active/A10_bunsin.asset new file mode 100644 index 0000000..c8c781b --- /dev/null +++ b/Assets/Resources/Skills/Active/A10_bunsin.asset @@ -0,0 +1,70 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 69566f3f65e99394d8a0ccd0b395ac77, type: 3} + m_Name: A10_bunsin + m_EditorClassIdentifier: Assembly-CSharp::EerieVillage.Skills.ActiveSkillData + CardId: A10 + DisplayName: "분신" + EnglishName: Clone + Icon: {fileID: 0} + Description: "25초마다 12초간 반투명 분신 + 1기 소환. 플레이어와 동일한 스킬을 + 0.5초 뒤 50% 데미지로 사용." + AttributeTags: 0 + TypeTags: 0 + maxLevel: 5 + Category: 3 + Trigger: 0 + BaseCooldown: 25 + BaseDamage: 0 + HitboxSize: {x: 0, y: 0} + OffsetDistance: {x: 0, y: 0} + EnableSecondHitbox: 0 + SecondHitboxSize: {x: 0, y: 0} + SecondOffsetDistance: {x: 0, y: 0} + Trajectory: 0 + MinionPrefab: {fileID: 0} + ChainCount: 0 + DotDuration: 0 + DotInterval: 0.5 + StunDuration: 0 + SlowDuration: 0 + SlowMultiplier: 0.5 + KnockbackForce: 0 + MaxConcurrent: 1 + MinionLifetime: 12 + AuraTickInterval: 1 + AuraRadius: 0 + CritDamageMultiplier: 2 + IFrameDuration: 0 + DebuffStackLimit: 0 + FireProbability: 1 + Range: 0 + MaxRange: 10 + ProjectileSpeed: 6 + ProjectilePrefab: {fileID: 0} + OnHitFxPrefab: {fileID: 0} + ExtraHitFxPrefab: {fileID: 0} + CastFxPrefab: {fileID: 0} + ProjectileAngleOffset: 0 + TargetEnemyOnFire: 0 + OnDotFxPrefab: {fileID: 0} + DotDamageMultiplier: 0.5 + ProjectileFxScale: 1 + HitFxScale: 1 + DotFxScale: 1 + FxRotation: 0 + OffsetXY: {x: 0, y: 0} + DamageFrameDelay: 0 + EnableRepeatDamage: 0 + MaxHitCount: 1 + RepeatFrameInterval: 30 diff --git a/Assets/Resources/Skills/Active/A10_bunsin.asset.meta b/Assets/Resources/Skills/Active/A10_bunsin.asset.meta new file mode 100644 index 0000000..3104244 --- /dev/null +++ b/Assets/Resources/Skills/Active/A10_bunsin.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a10b5c1ec5a72ba84b126e02c4d8f156 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Skills/Effectors/CloneEffector.cs b/Assets/Scripts/Skills/Effectors/CloneEffector.cs new file mode 100644 index 0000000..df4fdd1 --- /dev/null +++ b/Assets/Scripts/Skills/Effectors/CloneEffector.cs @@ -0,0 +1,24 @@ +namespace EerieVillage.Skills.Effectors +{ + /// + /// A10 분신 Effector (BT12-Dev-Clone · 2026-05-15). + /// + /// Category D (Minion) 영역 CardId == "A10" 분기에서 호출. + /// γ 단계 SkillFireEvent.Execute 영역 Minion case 분기: + /// if (data.CardId == "A10") effector = new CloneEffector(); + /// else effector = new SpiritFireSpawner(); // 기존 A11 + /// + /// 본 Effector는 분신 spawn (또는 Singleton 1기 교체)만 담당. + /// 분신 자체 발동은 CloneInstance.EnqueuePlayerFire (Player Fire hook 시점). + /// + public class CloneEffector : IEffector + { + public void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory) + { + if (runtime == null || inventory == null) return; + + // 분신 spawn 또는 교체 (Singleton 1기 유지·PD 결정) + CloneInstance.SpawnOrReplace(inventory, runtime.ActiveData); + } + } +} diff --git a/Assets/Scripts/Skills/Effectors/CloneEffector.cs.meta b/Assets/Scripts/Skills/Effectors/CloneEffector.cs.meta new file mode 100644 index 0000000..ebd25bb --- /dev/null +++ b/Assets/Scripts/Skills/Effectors/CloneEffector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eeea85fc8d91d75468cd46107e1caf27 \ No newline at end of file diff --git a/Assets/Scripts/Skills/Effectors/CloneInstance.cs b/Assets/Scripts/Skills/Effectors/CloneInstance.cs new file mode 100644 index 0000000..e54c40c --- /dev/null +++ b/Assets/Scripts/Skills/Effectors/CloneInstance.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using UnityEngine; +using Platformer.Mechanics; +using static Platformer.Core.Simulation; + +namespace EerieVillage.Skills.Effectors +{ + /// + /// A10 분신 인스턴스 (BT12-Dev-Clone · 2026-05-15). + /// + /// PD 직접 명세 (2026-05-15): + /// 1. 플레이어 x좌표 1 뒤쪽 spawn (facing 반대 방향 1유닛) + /// 2. 반투명 sprite (alpha 0.5) + /// 3. 플레이어와 동일한 스킬 사용 (장착 액티브 미러링) + /// 4. 분신 공격력 = 플레이어 공격력의 50% 반감 + /// 5. 플레이어보다 0.5초 뒤 사용 (공격 시작 딜레이) + /// + /// PD 추가 결정 (2026-05-15): + /// - BaseCooldown 25초 (PM 1차 30 → PD 조정 25) + /// - MinionLifetime 12초 자동 소멸 + Singleton 1기 유지 + /// - facing 고정 (spawn 시점 facing 고정·이동 시 분신 위치·방향 불변) + /// - 무적 = Collider 미부착 (적 투사체·벽·player·enemy 모두 통과) + /// - Lv 업 시 분신 수 증가 X · 추후 지속시간↑+플레이어 참조 데미지 비율(%)↑ + /// (balance-designer 후속 수치 확정) + /// + /// β 단계 의존성 (PlayerSkillInventory 신규 필드 추가 후 컴파일 통과): + /// - PlayerSkillInventory.OnPlayerSkillFired (event System.Action<ActiveSkillRuntime>) + /// - PlayerSkillInventory.IsCloneFireActive (bool · default false) + /// - PlayerSkillInventory.CloneFireOrigin (Vector2) + /// - PlayerSkillInventory.CloneFireFacingX (float) + /// - PlayerSkillInventory.CLONE_DAMAGE_MULTIPLIER (const 0.5f) + /// + public class CloneInstance : MonoBehaviour + { + // ───────────────────────────────────────────────────────────── + // PD 결정 상수 (2026-05-15) + // ───────────────────────────────────────────────────────────── + + /// PD 결정 — 분신 자동 소멸 시간 (초). Lv 업 시 추후 증가 (balance-designer). + public const float LIFETIME_SECONDS = 12f; + + /// PD 명세 — 공격 시작 딜레이 (초). + public const float FIRE_DELAY_SECONDS = 0.5f; + + /// PD 명세 — 분신 spawn 위치 (facing 반대 방향 1유닛). + public const float SPAWN_OFFSET_X = 1f; + + /// PD 명세 — 분신 sprite alpha (반투명). + public const float SPRITE_ALPHA = 0.5f; + + // ───────────────────────────────────────────────────────────── + // Singleton (PD 결정 — 분신 1기 유지·재발동 시 기존 destroy + 새 spawn) + // ───────────────────────────────────────────────────────────── + + private static CloneInstance _current; + + /// 현재 활성 분신 인스턴스 (테스트·진단용 · null = 분신 없음). + public static CloneInstance Current => _current; + + // ───────────────────────────────────────────────────────────── + // 상태 필드 + // ───────────────────────────────────────────────────────────── + + private PlayerSkillInventory _playerInventory; + // spawn 시점 Player facing.x sign (PD 결정 — 분신 facing 고정) + private float _spawnFacingX; + // spawn 시각 (lifetime 자동 소멸용 · timeScale 무관 unscaledTime) + private float _spawnTime; + + // 지연 큐 — Player Fire 시 0.5초 후 분신 발동 enqueue + private readonly Queue _pendingQueue = new Queue(); + + private struct PendingFire + { + public float TriggerTime; // Time.unscaledTime + FIRE_DELAY_SECONDS + public ActiveSkillRuntime Runtime; + } + + // ───────────────────────────────────────────────────────────── + // Spawn / Replace (Singleton 1기 유지) + // ───────────────────────────────────────────────────────────── + + /// + /// 분신 spawn 또는 기존 destroy + 새 spawn. + /// CloneEffector.Trigger 영역에서 호출 (A10 발동 시점). + /// + public static void SpawnOrReplace(PlayerSkillInventory playerInventory, ActiveSkillData cloneData) + { + if (playerInventory == null) return; + + // 1. 기존 분신 destroy (Singleton 1기 유지) + if (_current != null) + { + Destroy(_current.gameObject); + _current = null; + } + + // 2. Player 위치·facing 취득 + var pc = playerInventory.GetComponent(); + Vector2 facing = pc != null ? pc.Facing : Vector2.right; + float signX = facing.x < 0f ? -1f : 1f; + Vector2 playerPos = playerInventory.transform.position; + + // 3. PD 명세 — facing 반대 1유닛 (facing=오른쪽 → 분신 x = player.x - 1) + Vector2 spawnPos = playerPos + new Vector2(-signX * SPAWN_OFFSET_X, 0f); + + // 4. 신규 GameObject — sprite 복제 + alpha 0.5 + collider 미부착 (무적) + var go = new GameObject("Clone_A10"); + go.hideFlags = HideFlags.DontSave; // Scene 오염 방지 (BT12-Dev SOT 정합) + go.transform.position = spawnPos; + + var playerSr = playerInventory.GetComponentInChildren(); + if (playerSr != null) + { + var sr = go.AddComponent(); + sr.sprite = playerSr.sprite; + sr.flipX = playerSr.flipX; + sr.sortingOrder = playerSr.sortingOrder - 1; // Player 뒤쪽 sorting + Color c = playerSr.color; + c.a = SPRITE_ALPHA; // PD 명세 — 반투명 + sr.color = c; + } + + // PD 결정 — Collider 미부착 (무적·무충돌). Rigidbody2D도 미부착 (gravity·물리 차단). + + // 5. CloneInstance 컴포넌트 부착 + 상태 초기화 + var instance = go.AddComponent(); + instance._playerInventory = playerInventory; + instance._spawnFacingX = signX; + instance._spawnTime = Time.unscaledTime; + _current = instance; + + // 6. Player Fire 이벤트 구독 + // (β 단계 PlayerSkillInventory.OnPlayerSkillFired 신설 후 컴파일 통과) + playerInventory.OnPlayerSkillFired += instance.EnqueuePlayerFire; + } + + // ───────────────────────────────────────────────────────────── + // Player Fire hook (0.5초 지연 큐 enqueue) + // ───────────────────────────────────────────────────────────── + + /// + /// Player Fire 시점 hook (PlayerSkillInventory.OnPlayerSkillFired 이벤트 핸들러). + /// 0.5초 지연 후 분신 위치 anchor로 동일 Effector 재호출. + /// + public void EnqueuePlayerFire(ActiveSkillRuntime runtime) + { + if (runtime == null || runtime.ActiveData == null) return; + + // 무한 재귀 방지 — 분신은 A10 분신을 발동하지 않음 + if (runtime.ActiveData.CardId == "A10") return; + + _pendingQueue.Enqueue(new PendingFire + { + TriggerTime = Time.unscaledTime + FIRE_DELAY_SECONDS, + Runtime = runtime + }); + } + + // ───────────────────────────────────────────────────────────── + // Update — lifetime 자동 소멸 + 지연 큐 dequeue + // ───────────────────────────────────────────────────────────── + + void Update() + { + // PD 결정 (2026-05-15) — 12초 자동 소멸 + if (Time.unscaledTime - _spawnTime >= LIFETIME_SECONDS) + { + Destroy(gameObject); + return; + } + + // 지연 큐 dequeue — TriggerTime 도달 항목 발동 + while (_pendingQueue.Count > 0 && _pendingQueue.Peek().TriggerTime <= Time.unscaledTime) + { + var pending = _pendingQueue.Dequeue(); + FireAtClonePosition(pending.Runtime); + } + } + + // ───────────────────────────────────────────────────────────── + // 분신 위치 anchor Effector 재호출 (50% damage 분기는 PlayerSkillInventory.IsCloneFireActive 영역) + // ───────────────────────────────────────────────────────────── + + private void FireAtClonePosition(ActiveSkillRuntime runtime) + { + if (runtime == null || runtime.ActiveData == null || _playerInventory == null) return; + + // β 단계 — PlayerSkillInventory 신규 필드 set + // IsCloneFireActive = true + // → γ 단계 6 Effector 영역 anchor·facing·damage 분기 + // → ActiveSkillRuntime.CalculateEffectiveDamage 영역 50% multiplier + // CloneFireOrigin = transform.position (분신 위치 anchor) + // CloneFireFacingX = _spawnFacingX (분신 facing 고정) + _playerInventory.IsCloneFireActive = true; + _playerInventory.CloneFireOrigin = transform.position; + _playerInventory.CloneFireFacingX = _spawnFacingX; + + // SkillFireEvent.Execute 영역 그대로 재호출 (Effector 분기 영역 IsCloneFireActive 처리) + // reset은 γ 단계 SkillFireEvent.Cleanup 영역 IsCloneFireActive=false 일관 reset + // (설계 §A10-7 채택안 (가)) + var ev = Schedule(); + ev.Runtime = runtime; + ev.Inventory = _playerInventory; + } + + // ───────────────────────────────────────────────────────────── + // OnDestroy — Player Inventory hook unsubscribe + Singleton 정리 + // ───────────────────────────────────────────────────────────── + + void OnDestroy() + { + // Player Inventory hook unsubscribe (β 단계 OnPlayerSkillFired 신설 후 컴파일 통과) + if (_playerInventory != null) + { + _playerInventory.OnPlayerSkillFired -= EnqueuePlayerFire; + } + if (_current == this) _current = null; + } + } +} diff --git a/Assets/Scripts/Skills/Effectors/CloneInstance.cs.meta b/Assets/Scripts/Skills/Effectors/CloneInstance.cs.meta new file mode 100644 index 0000000..47a7173 --- /dev/null +++ b/Assets/Scripts/Skills/Effectors/CloneInstance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a3721d2092927044e85f3467e225015c \ No newline at end of file diff --git a/Assets/Scripts/Skills/Effectors/LaserSpawner.cs b/Assets/Scripts/Skills/Effectors/LaserSpawner.cs index 9e1bb0a..8edcabc 100644 --- a/Assets/Scripts/Skills/Effectors/LaserSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/LaserSpawner.cs @@ -23,12 +23,12 @@ namespace EerieVillage.Skills.Effectors // PD 지시 2026-05-13 분리 — OffsetDistance(forwardDir) = 히트박스만·OffsetXY = 이펙트만 Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ // PD 지시 2026-05-13 — FxRotation 은 이펙트(시각) 전용. 박스(판정) 은 facing 만 반영. float baseAngle = Mathf.Atan2(facing.y, facing.x) * Mathf.Rad2Deg; // 박스 회전 = facing 만 (좌/우) float fxAngle = baseAngle + data.FxRotation; // 이펙트 회전 = facing + FxRotation Vector2 boxForward = facing.normalized; // 박스 진행 방향 = facing (FxRotation 미적용) - Vector2 playerPos = inventory.transform.position; + Vector2 playerPos = inventory.GetSpawnAnchor(); Vector2 fxPos = playerPos + data.OffsetXY; // 이펙트 위치 // 이펙트 spawn — fxPos·HitFxScale·facing+FxRotation 적용 @@ -101,10 +101,10 @@ namespace EerieVillage.Skills.Effectors if (inventory == null) return; Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ Vector2 forwardDir = facing.normalized; // 판정 진행 방향 = facing 만 float signX = facing.x < 0f ? -1f : 1f; // OffsetDistance.x 좌/우 반전 - Vector2 origin = (Vector2)inventory.transform.position + Vector2 origin = (Vector2)inventory.GetSpawnAnchor() + new Vector2(signX * data.OffsetDistance.x, data.OffsetDistance.y); var enemies = Object.FindObjectsByType(FindObjectsSortMode.None); diff --git a/Assets/Scripts/Skills/Effectors/LightningStrikeSpawner.cs b/Assets/Scripts/Skills/Effectors/LightningStrikeSpawner.cs index 0ba9ef1..a6b8974 100644 --- a/Assets/Scripts/Skills/Effectors/LightningStrikeSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/LightningStrikeSpawner.cs @@ -51,7 +51,7 @@ namespace EerieVillage.Skills.Effectors } else { - primaryPos = (Vector2)inventory.transform.position; + primaryPos = inventory.GetSpawnAnchor(); // BT12-Dev-Clone (2026-05-15) γ } // PD 정합 2026-05-13 — OffsetDistance = (X, Y) 절대 오프셋·OffsetXY = 이펙트만 Vector2 hitboxPos = primaryPos + data.OffsetDistance; diff --git a/Assets/Scripts/Skills/Effectors/MeleeAreaSpawner.cs b/Assets/Scripts/Skills/Effectors/MeleeAreaSpawner.cs index a96db9a..da4e744 100644 --- a/Assets/Scripts/Skills/Effectors/MeleeAreaSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/MeleeAreaSpawner.cs @@ -20,12 +20,12 @@ namespace EerieVillage.Skills.Effectors { var data = runtime.ActiveData; // PD 지시 2026-05-13 — OffsetDistanceX = X 절대·OffsetDistance = Y 절대·OffsetXY = 이펙트만 - Vector2 playerPos = inventory.transform.position; + Vector2 playerPos = inventory.GetSpawnAnchor(); Vector2 fxPos = playerPos + data.OffsetXY; Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ // 이펙트 spawn — fxPos·HitFxScale·FxRotation·facing flip GameObject fxGo = null; @@ -116,9 +116,9 @@ namespace EerieVillage.Skills.Effectors // PD 지시 2026-05-13 — 박스(판정) = facing 좌/우 sign 만 · FxRotation 미적용 Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ float signX = facing.x < 0f ? -1f : 1f; - Vector2 playerPos = inventory.transform.position; + Vector2 playerPos = inventory.GetSpawnAnchor(); // 1차 판정 DoOverlapBoxAt(playerPos + new Vector2(signX * data.OffsetDistance.x, data.OffsetDistance.y), diff --git a/Assets/Scripts/Skills/Effectors/PoisonSwampSpawner.cs b/Assets/Scripts/Skills/Effectors/PoisonSwampSpawner.cs index 10e4bd0..9f7959e 100644 --- a/Assets/Scripts/Skills/Effectors/PoisonSwampSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/PoisonSwampSpawner.cs @@ -28,7 +28,7 @@ namespace EerieVillage.Skills.Effectors EnemyController nearest = null; float minDist = float.MaxValue; - Vector2 playerPos = inventory.transform.position; + Vector2 playerPos = inventory.GetSpawnAnchor(); // BT12-Dev-Clone (2026-05-15) γ var enemies = Object.FindObjectsByType(FindObjectsSortMode.None); foreach (var e in enemies) { diff --git a/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs b/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs index a73385a..3bdb836 100644 --- a/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/ProjectileSpawner.cs @@ -20,18 +20,20 @@ namespace EerieVillage.Skills.Effectors // 플레이어 위치·방향 취득 Transform playerTransform = inventory.transform; + // BT12-Dev-Clone (2026-05-15) — A10 분신 anchor (γ 단계 helper) + Vector2 anchorPos = inventory.GetSpawnAnchor(); // PlayerController.Facing 참조 Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ // PD 지시 2026-05-13 — 게임 시작 시 Player.Facing=(0,0) 영역 fallback (정지·잔존 투사체 차단) if (facing.sqrMagnitude < 0.01f) facing = Vector2.right; // PD 지시 2026-05-14 — TargetEnemyOnFire 영역 가장 가까운 적 방향 영역 발사 (A08 저주의 화살 등) if (data.TargetEnemyOnFire) { - Vector2 playerPos2 = playerTransform.position; + Vector2 playerPos2 = anchorPos; EnemyController nearest = null; float minDist = float.MaxValue; var enemies = Object.FindObjectsByType(FindObjectsSortMode.None); @@ -54,7 +56,7 @@ namespace EerieVillage.Skills.Effectors // PD 정합 2026-05-13 — OffsetDistance.x = facing 방향 거리·OffsetDistance.y = 직각 거리·OffsetXY = 이펙트 절대 Vector2 perpDir = new Vector2(-facing.y, facing.x); - Vector2 spawnPos = (Vector2)playerTransform.position + Vector2 spawnPos = (Vector2)anchorPos + facing * data.OffsetDistance.x + perpDir * data.OffsetDistance.y + data.OffsetXY; @@ -64,7 +66,7 @@ namespace EerieVillage.Skills.Effectors if (data.CastFxPrefab != null) { float castAngle = Mathf.Atan2(facing.y, facing.x) * Mathf.Rad2Deg + data.ProjectileAngleOffset + data.FxRotation; - var castFx = Object.Instantiate(data.CastFxPrefab, playerTransform.position, Quaternion.Euler(0f, 0f, castAngle)); + var castFx = Object.Instantiate(data.CastFxPrefab, anchorPos, Quaternion.Euler(0f, 0f, castAngle)); castFx.hideFlags = HideFlags.DontSave; castFx.transform.localScale *= data.HitFxScale; foreach (var ps in castFx.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } diff --git a/Assets/Scripts/Skills/Effectors/SpiritFireSpawner.cs b/Assets/Scripts/Skills/Effectors/SpiritFireSpawner.cs index 85e8a7b..553a4bb 100644 --- a/Assets/Scripts/Skills/Effectors/SpiritFireSpawner.cs +++ b/Assets/Scripts/Skills/Effectors/SpiritFireSpawner.cs @@ -17,7 +17,7 @@ namespace EerieVillage.Skills.Effectors public void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory) { var data = runtime.ActiveData; - Vector2 spawnPos = (Vector2)inventory.transform.position + data.OffsetXY; + Vector2 spawnPos = inventory.GetSpawnAnchor() + data.OffsetXY; // BT12-Dev-Clone (2026-05-15) γ GameObject shieldGo; if (data.OnHitFxPrefab != null) @@ -47,7 +47,7 @@ namespace EerieVillage.Skills.Effectors // PD 지시 2026-05-14 — OffsetDistance 적용 (facing sign · A05·Laser 동일 패턴) Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); - if (pc != null) facing = pc.Facing; + facing = inventory.GetSpawnFacing(pc); // BT12-Dev-Clone (2026-05-15) γ float signX = facing.x < 0f ? -1f : 1f; Vector2 offset = new Vector2(signX * data.OffsetDistance.x, data.OffsetDistance.y); diff --git a/Assets/Scripts/Skills/Events/SkillFireEvent.cs b/Assets/Scripts/Skills/Events/SkillFireEvent.cs index f8eea40..53047df 100644 --- a/Assets/Scripts/Skills/Events/SkillFireEvent.cs +++ b/Assets/Scripts/Skills/Events/SkillFireEvent.cs @@ -50,7 +50,9 @@ namespace EerieVillage.Skills break; case ActiveCategory.Minion: - effector = new SpiritFireSpawner(); + // BT12-Dev-Clone (2026-05-15) — A10 분신 CardId 분기 (설계 §A10-7) + if (data.CardId == "A10") effector = new CloneEffector(); + else effector = new SpiritFireSpawner(); break; // Phase 2-C~ 예정: Debuff·SpecialJudge @@ -63,6 +65,10 @@ namespace EerieVillage.Skills internal override void Cleanup() { + // BT12-Dev-Clone (2026-05-15) — 분신 발동 컨텍스트 reset (설계 §A10-7 채택안 (가)) + // Effector.Trigger 완료 후 IsCloneFireActive=false 일관 reset + // (Simulation Event 일관 시점 — 다음 frame race 회피) + if (Inventory != null) Inventory.IsCloneFireActive = false; Runtime = null; Inventory = null; } diff --git a/Assets/Scripts/Skills/Runtime/ActiveSkillRuntime.cs b/Assets/Scripts/Skills/Runtime/ActiveSkillRuntime.cs index d4eb7a7..27e404b 100644 --- a/Assets/Scripts/Skills/Runtime/ActiveSkillRuntime.cs +++ b/Assets/Scripts/Skills/Runtime/ActiveSkillRuntime.cs @@ -94,6 +94,11 @@ namespace EerieVillage.Skills var ev = Simulation.Schedule(); ev.Runtime = this; ev.Inventory = _inventory; + + // BT12-Dev-Clone (2026-05-15) — A10 분신 hook 발화 + // IsCloneFireActive 분기 — 분신 발동 시 OnPlayerSkillFired 발화 X (무한 재귀 방지) + if (_inventory != null && !_inventory.IsCloneFireActive) + _inventory.OnPlayerSkillFired?.Invoke(this); } /// @@ -129,12 +134,16 @@ namespace EerieVillage.Skills attrMult = v; } - return Mathf.RoundToInt( - ActiveData.BaseDamage * + float effective = ActiveData.BaseDamage * stats.DamageMultiplier * StackLevelFactor(StackLevel) * - attrMult - ); + attrMult; + + // BT12-Dev-Clone (2026-05-15) — A10 분신 발동 시 damage 50% 반감 (PD 명세 4번) + if (_inventory.IsCloneFireActive) + effective *= PlayerSkillInventory.CLONE_DAMAGE_MULTIPLIER; + + return Mathf.RoundToInt(effective); } } } diff --git a/Assets/Scripts/Skills/Runtime/PlayerSkillInventory.cs b/Assets/Scripts/Skills/Runtime/PlayerSkillInventory.cs index 99c3e94..c2dedca 100644 --- a/Assets/Scripts/Skills/Runtime/PlayerSkillInventory.cs +++ b/Assets/Scripts/Skills/Runtime/PlayerSkillInventory.cs @@ -44,6 +44,46 @@ namespace EerieVillage.Skills public event System.Action OnEnemyKilled; public event System.Action OnPlayerDamaged; + // ───────────────────────────────────────────────────────────── + // BT12-Dev-Clone (2026-05-15) — A10 분신 영역 (CloneInstance·CloneEffector hook) + // ───────────────────────────────────────────────────────────── + + /// Player 스킬 발동 시 호출 (CloneInstance.EnqueuePlayerFire hook). + public event System.Action OnPlayerSkillFired; + + /// 분신 발동 컨텍스트 분기 플래그. Effector 영역 anchor·facing·damage 분기 적용. + internal bool IsCloneFireActive = false; + + /// 분신 발동 시 anchor 위치 (분신 GameObject 위치). + internal Vector2 CloneFireOrigin = Vector2.zero; + + /// 분신 발동 시 facing sign (-1 = 왼쪽·+1 = 오른쪽). spawn 시점 고정 (PD 결정 2026-05-15). + internal float CloneFireFacingX = 1f; + + /// PD 결정 (2026-05-15) — 분신 damage multiplier. 50% 반감. + internal const float CLONE_DAMAGE_MULTIPLIER = 0.5f; + + /// + /// 스킬 발동 anchor 위치 반환 (BT12-Dev-Clone 2026-05-15 γ 단계). + /// IsCloneFireActive=true → 분신 위치 (CloneFireOrigin) · false → Player 위치. + /// 6 Effector 영역 일관 사용 — Player·분신 발동 위치 분기 단일 진입점. + /// + public Vector2 GetSpawnAnchor() + { + return IsCloneFireActive ? CloneFireOrigin : (Vector2)transform.position; + } + + /// + /// 스킬 발동 facing 반환 (BT12-Dev-Clone 2026-05-15 γ 단계). + /// IsCloneFireActive=true → 분신 facing 고정 (CloneFireFacingX) · false → Player facing. + /// 6 Effector 영역 일관 사용. + /// + 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() diff --git a/Assets/Scripts/Skills/Runtime/SkillRuntimeFactory.cs b/Assets/Scripts/Skills/Runtime/SkillRuntimeFactory.cs index 02cd4dd..6fb872f 100644 --- a/Assets/Scripts/Skills/Runtime/SkillRuntimeFactory.cs +++ b/Assets/Scripts/Skills/Runtime/SkillRuntimeFactory.cs @@ -76,7 +76,9 @@ namespace EerieVillage.Skills // PD 지시 2026-05-13 Phase A — A08 저주의 화살·A12 정화의 빛 신규 추가 "A08", "A12", // PD 지시 2026-05-13 Phase B — A06 독 늪·A11 정령불 신규 추가 - "A06", "A11" + "A06", "A11", + // BT12-Dev-Clone (2026-05-15) — A10 분신 신규 + "A10" }; /// diff --git a/Assets/Tests/Editor/CloneSkillTests.cs b/Assets/Tests/Editor/CloneSkillTests.cs new file mode 100644 index 0000000..98cfdce --- /dev/null +++ b/Assets/Tests/Editor/CloneSkillTests.cs @@ -0,0 +1,123 @@ +using NUnit.Framework; +using UnityEngine; +using EerieVillage.Skills; +using EerieVillage.Skills.Effectors; + +namespace EerieVillage.Skills.Tests.Editor +{ + /// + /// A10 분신 스킬 EditMode 테스트 (BT12-Dev-Clone · 2026-05-15 δ 단계). + /// + /// PD 명세 5항목 + PD 결정 4건 정합 검증. + /// PlayMode 의존 (Time.unscaledTime·Update) 영역은 본 EditMode에서 제외 — PD Play 검증 영역. + /// + public class CloneSkillTests + { + // ───────────────────────────────────────────────────────────── + // 1. PD 결정 상수 검증 (BaseCooldown 25·MinionLifetime 12·딜레이 0.5·offset 1·alpha 0.5) + // ───────────────────────────────────────────────────────────── + + [Test] + public void T01_CloneInstance_PdConstantsMatch() + { + Assert.AreEqual(12f, CloneInstance.LIFETIME_SECONDS, + "PD 결정 (2026-05-15) — 분신 lifetime 12초."); + Assert.AreEqual(0.5f, CloneInstance.FIRE_DELAY_SECONDS, + "PD 명세 — 공격 시작 딜레이 0.5초."); + Assert.AreEqual(1f, CloneInstance.SPAWN_OFFSET_X, + "PD 명세 — facing 반대 방향 1유닛."); + Assert.AreEqual(0.5f, CloneInstance.SPRITE_ALPHA, + "PD 명세 — 반투명 alpha 0.5."); + } + + [Test] + public void T02_PlayerSkillInventory_CloneDamageMultiplierIsHalf() + { + // PD 명세 — 분신 공격력 50% 반감 (= 0.5f multiplier) + Assert.AreEqual(0.5f, PlayerSkillInventory.CLONE_DAMAGE_MULTIPLIER, + "PD 명세 4번 — 분신 damage 50% 반감."); + } + + // ───────────────────────────────────────────────────────────── + // 2. helper 메서드 동작 검증 (γ 단계 anchor·facing 분기) + // ───────────────────────────────────────────────────────────── + + [Test] + public void T03_GetSpawnAnchor_ReturnsPlayerPositionWhenNotCloneFire() + { + var go = new GameObject("PlayerStub"); + go.AddComponent(); + var inv = go.AddComponent(); + go.transform.position = new Vector3(5f, 3f, 0f); + inv.IsCloneFireActive = false; + + Vector2 anchor = inv.GetSpawnAnchor(); + Assert.AreEqual(5f, anchor.x, "기본 영역 — Player x 위치 반환."); + Assert.AreEqual(3f, anchor.y, "기본 영역 — Player y 위치 반환."); + + Object.DestroyImmediate(go); + } + + [Test] + public void T04_GetSpawnAnchor_ReturnsCloneOriginWhenCloneFireActive() + { + var go = new GameObject("PlayerStub"); + go.AddComponent(); + var inv = go.AddComponent(); + go.transform.position = new Vector3(5f, 3f, 0f); + inv.IsCloneFireActive = true; + inv.CloneFireOrigin = new Vector2(-2f, 7f); + + Vector2 anchor = inv.GetSpawnAnchor(); + Assert.AreEqual(-2f, anchor.x, "분신 발동 영역 — 분신 위치 반환."); + Assert.AreEqual(7f, anchor.y, "분신 발동 영역 — 분신 위치 반환."); + + Object.DestroyImmediate(go); + } + + [Test] + public void T05_GetSpawnFacing_ReturnsCloneFacingWhenCloneFireActive() + { + var go = new GameObject("PlayerStub"); + go.AddComponent(); + var inv = go.AddComponent(); + inv.IsCloneFireActive = true; + inv.CloneFireFacingX = -1f; // 분신 왼쪽 facing 고정 + + Vector2 facing = inv.GetSpawnFacing(null); + Assert.AreEqual(-1f, facing.x, "분신 facing 고정 (x sign)."); + Assert.AreEqual(0f, facing.y, "분신 facing y = 0."); + + Object.DestroyImmediate(go); + } + + // ───────────────────────────────────────────────────────────── + // 3. CloneInstance Singleton·재귀 skip 검증 + // ───────────────────────────────────────────────────────────── + + [Test] + public void T06_CloneInstance_CurrentIsNullInitially() + { + // 정적 Singleton 초기 영역 null (또는 이전 테스트 잔존 정리) + if (CloneInstance.Current != null) + Object.DestroyImmediate(CloneInstance.Current.gameObject); + + Assert.IsNull(CloneInstance.Current, "분신 미spawn 영역 Current = null."); + } + + [Test] + public void T07_EnqueuePlayerFire_IgnoresA10RecursionPrep() + { + // 분신이 A10 발동 hook 영역 무한 재귀 방지 영역 — null·CardId == "A10" 즉시 return + // 본 테스트는 hook 영역 진입 자체가 null safe 영역 확증. + var go = new GameObject("CloneStub"); + var instance = go.AddComponent(); + + // null runtime 영역 즉시 return — Exception X + Assert.DoesNotThrow(() => instance.EnqueuePlayerFire(null), + "null runtime hook 영역 NullReferenceException X."); + + Object.DestroyImmediate(go); + } + } +} diff --git a/Assets/Tests/Editor/CloneSkillTests.cs.meta b/Assets/Tests/Editor/CloneSkillTests.cs.meta new file mode 100644 index 0000000..69a08b2 --- /dev/null +++ b/Assets/Tests/Editor/CloneSkillTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c10e7e575c0e15a72ba8d8f156b126e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: