using UnityEngine; using Platformer.Mechanics; using EerieVillage.Skills; 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; // PlayerController.Facing 참조 Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); if (pc != null) facing = pc.Facing; // 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; EnemyController nearest = null; float minDist = float.MaxValue; var enemies = Object.FindObjectsByType(FindObjectsSortMode.None); foreach (var e in enemies) { if (e == null) continue; var h = e.GetComponent(); if (h == null || !h.IsAlive) continue; float d = Vector2.Distance(playerPos2, e.transform.position); if (d < minDist) { minDist = d; nearest = e; } } if (nearest != null) { Vector2 toEnemy = (Vector2)nearest.transform.position - playerPos2; if (toEnemy.sqrMagnitude > 0.01f) facing = toEnemy.normalized; } } // PD 정합 2026-05-13 — OffsetDistance.x = facing 방향 거리·OffsetDistance.y = 직각 거리·OffsetXY = 이펙트 절대 Vector2 perpDir = new Vector2(-facing.y, facing.x); Vector2 spawnPos = (Vector2)playerTransform.position + facing * data.OffsetDistance.x + perpDir * data.OffsetDistance.y + data.OffsetXY; // PD 지시 2026-05-13 — 시전 FX (A08 저주의 화살 등) Player 위치 spawn // facing+ProjectileAngleOffset+FxRotation 영역 적용 (Projectile 본체 동일 패턴·sprite 반대 방향 정정) 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)); 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); } FxAutoDestroyUnscaled.Attach(castFx, 2f); } // 프리팹 로드 (data.ProjectilePrefab 우선·없으면 fallback) GameObject prefab = LoadProjectilePrefab(data); // 다중 발사 수 (기본 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); // BT12-Dev 2026-05-09 잔존 투사체 fix: // prefab 부재 시 Scene GameObject를 prefab으로 Instantiate하면 원본 Scene GO가 잔존. // → fallback 영역 매번 새 GameObject 직접 생성 (Instantiate X·자기 자신 발사체). GameObject go = prefab != null ? Object.Instantiate(prefab, (Vector3)spawnPos, Quaternion.identity) : CreateFallbackProjectile(data, (Vector3)spawnPos); // PD 지시 2026-05-13 — 런타임 spawn 투사체 Scene 저장 회피 (Edit Mode execute 시 잔존 방지) go.hideFlags = HideFlags.DontSave; // PD 지시 2026-05-13 — ParticleSystem 명시 Play (ProjectilePrefab 영역 자체 FX·A08 FX_PinkMagicArrow 등) foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } // PD 지시 2026-05-13 — 시각화 ↔ 판정 정합 — BoxCollider2D size = HitboxSize·isTrigger=true. // FX prefab 영역 기존 Collider2D 있으면 size 만 정합·없으면 신규 BoxCollider2D 부착. var existingCol = go.GetComponent(); BoxCollider2D box; if (existingCol is BoxCollider2D) { box = (BoxCollider2D)existingCol; } else { if (existingCol != null) Object.Destroy(existingCol); box = go.AddComponent(); } box.isTrigger = true; box.size = data.HitboxSize; box.offset = Vector2.zero; Projectile proj; if (data.Trajectory == ProjectileTrajectory.Homing) proj = go.GetComponent() ?? go.AddComponent(); else if (data.Trajectory == ProjectileTrajectory.Arc) // PD 지시 2026-05-13 — A13 천둥발 관통 영역 PiercingProjectile (Arc 영역 영역 영역) proj = go.GetComponent() ?? go.AddComponent(); else proj = go.GetComponent() ?? go.AddComponent(); proj.Initialize(runtime, inventory, dir); } } /// /// 투사체 prefab 로드. data.ProjectilePrefab (Phase 2-E 신규 필드) 우선·부재 시 Resources fallback. /// private static GameObject LoadProjectilePrefab(ActiveSkillData data = null) { if (data != null && data.ProjectilePrefab != null) return data.ProjectilePrefab; return Resources.Load("Skills/Projectiles/Default"); } /// /// fallback 발사체 GameObject 직접 생성. Instantiate 영역 X — 자기 자신 발사체. /// BT12-Dev 2026-05-09 — 이전 LoadProjectilePrefab fallback 영역에서 분리. /// 원본 Scene GameObject 영구 잔존 버그 근본 해결 (PD 보고 — 맵에 투사체 영구 잔존). /// PD 지시 2026-05-09 시각화 — 16×16 RGBA32 동적 알파 원 Texture2D + 속성별 색상. /// private static GameObject CreateFallbackProjectile(ActiveSkillData data, Vector3 spawnPos) { var go = new GameObject($"Projectile_{data.CardId}"); go.transform.position = spawnPos; var col = go.AddComponent(); col.isTrigger = true; col.radius = 0.2f; // BT12-Dev 2026-05-09 — Rigidbody2D 부재 유지 (Static Collider). // Enemy = KinematicObject 상속 → Rigidbody2D Kinematic. // Static vs Kinematic = OnTriggerEnter2D 발화 정합 (Unity 2D Physics 표준). // 직전 시도 Kinematic Rigidbody2D 추가는 Kinematic vs Kinematic 영역 OnTriggerEnter2D 발화 X 영역 회귀 → 폐기. var sr = go.AddComponent(); sr.sprite = GetOrCreateFallbackSprite(); sr.color = GetColorByAttribute(data.AttributeTags); sr.sortingOrder = 50; // Player·Enemy 위 // 0.4 unit 직경 정도의 작은 원 go.transform.localScale = new Vector3(0.4f, 0.4f, 1f); return go; } // 캐시: Sprite 매번 생성하지 않도록 정적 보존 static Sprite _fallbackSprite; static Sprite GetOrCreateFallbackSprite() { if (_fallbackSprite != null) return _fallbackSprite; // 16×16 원형 알파 텍스처 (둥근 형태) const int size = 16; var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); tex.wrapMode = TextureWrapMode.Clamp; tex.filterMode = FilterMode.Bilinear; float r = size * 0.5f; for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { float dx = x - r + 0.5f; float dy = y - r + 0.5f; float dist = Mathf.Sqrt(dx * dx + dy * dy); float alpha = Mathf.Clamp01(1f - (dist - (r - 1.5f))); tex.SetPixel(x, y, new Color(1f, 1f, 1f, alpha)); } } tex.Apply(); _fallbackSprite = Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), size); return _fallbackSprite; } static Color GetColorByAttribute(AttributeTag attr) { // 속성별 색상 (시각 구분 영역) if ((attr & AttributeTag.Fire) != 0) return new Color(1f, 0.5f, 0.2f); if ((attr & AttributeTag.Frost) != 0) return new Color(0.5f, 0.85f, 1f); if ((attr & AttributeTag.Dark) != 0) return new Color(0.6f, 0.3f, 0.85f); if ((attr & AttributeTag.Lightning) != 0) return new Color(1f, 1f, 0.4f); if ((attr & AttributeTag.Physical) != 0) return new Color(0.95f, 0.95f, 0.95f); return Color.white; } 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); } } }