347 lines
17 KiB
C#
347 lines
17 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using Platformer.Mechanics;
|
||
using Platformer.Gameplay;
|
||
using static Platformer.Core.Simulation;
|
||
|
||
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 = 6f; // BT12-Dev 2026-05-10 PD — 거리 차이 체감 영역 영역 (12 → 6)
|
||
protected float _lifetime = 5f; // 영역 영역 영역 (Long 18.67 / 6 = 3.11s 영역 영역)
|
||
|
||
// BT12-Dev 2026-05-10 (PD #1·#2) — 거리 제한·벽 충돌 영역
|
||
protected Vector2 _spawnPosition;
|
||
protected float _maxRange;
|
||
protected float _spawnTime;
|
||
|
||
// 동일 투사체로 동일 Collider 중복 타격 방지
|
||
protected readonly HashSet<Collider2D> _hitTargets = new HashSet<Collider2D>();
|
||
|
||
// BT12-Dev 2026-05-13 — 페이드아웃·축소 (PD 지시: 사거리 80%~100% 영역 alpha 0·scale 추가 50% 축소)
|
||
protected Vector3 _baseScale; // 페이드 보간 기준 (= _originalScale × ProjectileFxScale·매 frame 갱신)
|
||
protected Vector3 _originalScale; // PD 정합 2026-05-13 — Inspector ProjFxScale 변경 영역 매 frame 영역 영역 base
|
||
protected Renderer[] _renderers;
|
||
protected MaterialPropertyBlock _mpb;
|
||
protected float[] _baseAlphas;
|
||
const float FADE_START_RATIO = 0.85f;
|
||
|
||
/// <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();
|
||
|
||
// PD 지시 2026-05-13 — 투사체 root = 박스(판정) 정합. FxRotation 미적용 (시각 전용·박스 회전 금지).
|
||
// ProjectileAngleOffset = sprite 기본 방향 보정 (예: A08 FX_PinkMagicArrow sprite left → 180)
|
||
float angle = Mathf.Atan2(_direction.y, _direction.x) * Mathf.Rad2Deg + _data.ProjectileAngleOffset;
|
||
transform.rotation = Quaternion.Euler(0f, 0f, angle);
|
||
|
||
// BT12-Dev 2026-05-10 (PD #1) — 거리 제한 영역 영역 spawn 위치 저장
|
||
_spawnPosition = transform.position;
|
||
// PD 지시 2026-05-13 — Time.timeScale=0 (LevelUp 카드 선택) 영역 Time.time 정지 → unscaledTime 영역 lifetime check 정합
|
||
_spawnTime = Time.unscaledTime;
|
||
|
||
// PD 지시 2026-05-13 — 투사체 사정거리·속도 Inspector 직접 조절 (RangeTier·camWidth·mults 계산 폐기)
|
||
_maxRange = (_data.MaxRange > 0.01f) ? _data.MaxRange : 10f;
|
||
_speed = (_data.ProjectileSpeed > 0.01f) ? _data.ProjectileSpeed : 6f;
|
||
|
||
// PD 정합 2026-05-13 — Inspector ProjFxScale 매 frame 영역 영역 _originalScale 저장
|
||
_originalScale = transform.localScale;
|
||
transform.localScale *= _data.ProjectileFxScale;
|
||
_baseScale = transform.localScale;
|
||
|
||
// PD 지시 2026-05-13 — 판정 영역 시각화 (자체 Collider2D bounds 영역 자식 박스·이동·페이드 정합)
|
||
SpawnHitboxDebugChild();
|
||
|
||
// PD 지시 2026-05-13 — 투사체 사거리 파란 박스 시각화 (발사 후 3초 유지)
|
||
HitboxDebug.SpawnRange(_spawnPosition, _direction, _maxRange, 3f);
|
||
|
||
// Renderer·MaterialPropertyBlock 캐싱 + 기본 alpha 저장
|
||
_renderers = GetComponentsInChildren<Renderer>();
|
||
_mpb = new MaterialPropertyBlock();
|
||
_baseAlphas = new float[_renderers.Length];
|
||
for (int i = 0; i < _renderers.Length; i++)
|
||
{
|
||
var r = _renderers[i];
|
||
if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_TintColor"))
|
||
_baseAlphas[i] = r.sharedMaterial.GetColor("_TintColor").a;
|
||
else if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_Color"))
|
||
_baseAlphas[i] = r.sharedMaterial.GetColor("_Color").a;
|
||
else _baseAlphas[i] = 1f;
|
||
}
|
||
|
||
// PD 지시 2026-05-13 — Invoke 폐기 (Time.timeScale=0 영역 호출 X·영구 잔존 원인)·unscaledTime backup 영역 lifetime 영역 보장
|
||
// CancelInvoke 호출 영역 추가 안전 (이전 영역 영역 Invoke 영역 잔존 차단)
|
||
CancelInvoke();
|
||
}
|
||
|
||
// BT12-Dev 2026-05-13 — 사거리 80~100% 영역 scale·alpha 보간 (PD 지시)
|
||
// ratio < 0.8 : scale=1.0배 (시작 50% 그대로)·alpha 그대로
|
||
// ratio 0.8~1.0 : t=(ratio-0.8)/0.2 → scale = base × (1 - 0.5t)·alpha = base × (1 - t)
|
||
protected void ApplyFadeoutByRange(float ratio)
|
||
{
|
||
if (_renderers == null) return;
|
||
float t = Mathf.Clamp01((ratio - FADE_START_RATIO) / (1f - FADE_START_RATIO));
|
||
float scaleMul = 1f - 0.5f * t;
|
||
float alphaMul = 1f - t;
|
||
transform.localScale = _baseScale * scaleMul;
|
||
|
||
for (int i = 0; i < _renderers.Length; i++)
|
||
{
|
||
var r = _renderers[i];
|
||
if (r == null) continue;
|
||
r.GetPropertyBlock(_mpb);
|
||
string propName = (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_TintColor"))
|
||
? "_TintColor"
|
||
: "_Color";
|
||
Color baseCol = (r.sharedMaterial != null && r.sharedMaterial.HasProperty(propName))
|
||
? r.sharedMaterial.GetColor(propName)
|
||
: Color.white;
|
||
baseCol.a = _baseAlphas[i] * alphaMul;
|
||
_mpb.SetColor(propName, baseCol);
|
||
r.SetPropertyBlock(_mpb);
|
||
}
|
||
}
|
||
|
||
// BT12-Dev 2026-05-10 회귀 정정 — Wall Layer 실측 (Player Layer=13·Enemy Layer=14·Level Tilemap Layer=0).
|
||
// Layer 0 (Default) Solid Collider: Level TilemapCollider2D + GameObject·Alien BoxCollider2D — Wall 정합.
|
||
// Trigger collider (CinemachineConfiner Polygon·Token·DeathZone Box) = useTriggers=false 영역 자동 제외.
|
||
// Player·Enemy 영역 Layer 0 외 영역 → OverlapPoint hit 무관.
|
||
protected static readonly int WallLayerMask = (1 << 0);
|
||
|
||
protected virtual void Update()
|
||
{
|
||
// PD 지시 2026-05-13 — Initialize 호출 이전 Update 발화 차단·_data null 시 즉시 SelfDestruct (잔존 차단)
|
||
if (_data == null)
|
||
{
|
||
SelfDestruct();
|
||
return;
|
||
}
|
||
|
||
// PD 지시 2026-05-13 — unscaledTime 영역 lifetime check (Time.timeScale=0 영역 Time.time 정지 영역 회피·재시작 시 영구 잔존 차단)
|
||
if (_spawnTime > 0f && Time.unscaledTime - _spawnTime > _lifetime)
|
||
{
|
||
SelfDestruct();
|
||
return;
|
||
}
|
||
|
||
// PD 지시 2026-05-13 — Inspector HitboxSize 변경 즉시 반영
|
||
SyncHitboxToData();
|
||
|
||
transform.position += (Vector3)(_direction * _speed * Time.deltaTime);
|
||
|
||
// BT12-Dev 2026-05-10 (PD #1) — 거리 제한 영역 영역 SelfDestruct
|
||
float dist = Vector2.Distance(transform.position, _spawnPosition);
|
||
if (dist >= _maxRange)
|
||
{
|
||
SelfDestruct();
|
||
return;
|
||
}
|
||
|
||
// BT12-Dev 2026-05-13 — 사거리 80~100% 영역 페이드아웃·축소 (PD 지시)
|
||
if (_maxRange > 0.01f)
|
||
{
|
||
ApplyFadeoutByRange(dist / _maxRange);
|
||
}
|
||
|
||
// BT12-Dev 2026-05-10 (PD #2 fix·재발 정정 #2) — Wall OverlapPoint·useTriggers=false (CinemachineConfiner Trigger 영역 영역 영역).
|
||
// grace period 0.05s 영역 spawn 시점 즉시 SelfDestruct 회피·PD 지시 2026-05-13 unscaledTime 정합.
|
||
if (Time.unscaledTime - _spawnTime > 0.05f)
|
||
{
|
||
var filter = new ContactFilter2D();
|
||
filter.useTriggers = false; // Trigger collider (CinemachineConfiner 영역) 영역 영역
|
||
filter.useLayerMask = true;
|
||
filter.layerMask = WallLayerMask;
|
||
var results = new Collider2D[1];
|
||
int hitCount = Physics2D.OverlapPoint(transform.position, filter, results);
|
||
if (hitCount > 0)
|
||
{
|
||
SelfDestruct();
|
||
}
|
||
}
|
||
}
|
||
|
||
protected virtual void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
// PD 지시 2026-05-13 — Initialize 호출 이전 OnTriggerEnter2D 발화 영역 NullReferenceException 차단
|
||
// ProjectileSpawner.Trigger 영역 collider 부착 후 Initialize 호출 영역 race 영역 영역 발화 가능
|
||
if (_runtime == null || _data == null) return;
|
||
|
||
// PD 지시 2026-05-13 — spawn 직후 0.1초 grace (Player 근접 Enemy 즉시 hit 영역 OnHitFx Player 위치 표시 회피)
|
||
if (Time.unscaledTime - _spawnTime < 0.1f) return;
|
||
|
||
if (_hitTargets.Contains(other)) return;
|
||
|
||
// PD 지시 2026-05-13 — 투사체끼리 충돌 X·통과 정합 (Projectile 컴포넌트 동족 skip·Wall 판정 이전)
|
||
if (other.GetComponent<Projectile>() != null) return;
|
||
|
||
// PD 지시 2026-05-09 후속 방어 — 자기(Player) hit·자기 자신·hit 방어.
|
||
if (other.GetComponent<PlayerController>() != null) return;
|
||
|
||
// Enemy 레이어 한정.
|
||
int enemyLayer = LayerMask.NameToLayer("Enemy");
|
||
bool isEnemy = (enemyLayer != -1 && other.gameObject.layer == enemyLayer)
|
||
|| other.GetComponent<EnemyController>() != null;
|
||
|
||
if (isEnemy)
|
||
{
|
||
var health = other.GetComponent<Health>();
|
||
// PD 지시 2026-05-13 — 죽은 Enemy 와 충돌 시도해도 Projectile 영역 SelfDestruct (영역 영역 통과 회피)
|
||
if (health == null || !health.IsAlive) { SelfDestruct(); return; }
|
||
|
||
_hitTargets.Add(other);
|
||
|
||
// 유효 대미지 산출 — BT12-Dev 2026-05-10 임시 (PD 지시): 기본 공격력 5 하한 강제.
|
||
int damage = Mathf.Max(_runtime.CalculateEffectiveDamage(), 5);
|
||
|
||
// 피해 적용
|
||
health.Decrement(damage);
|
||
|
||
// PD 지시 2026-05-13 — 피격 이펙트 spawn·HitFxScale·FxRotation 적용
|
||
if (_data != null && _data.OnHitFxPrefab != null)
|
||
{
|
||
var fx = Object.Instantiate(_data.OnHitFxPrefab, other.transform.position, Quaternion.Euler(0f, 0f, _data.FxRotation));
|
||
fx.hideFlags = HideFlags.DontSave; // PD 지시 2026-05-13 — Scene 저장 회피
|
||
fx.transform.localScale *= _data.HitFxScale;
|
||
// PD 지시 2026-05-13 — ParticleSystem 명시 Play
|
||
foreach (var ps in fx.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); }
|
||
// PD 지시 2026-05-14 — 피격 이펙트 상위 sortingOrder (Enemy SpriteRenderer 영역 위)
|
||
foreach (var r in fx.GetComponentsInChildren<Renderer>(true)) r.sortingOrder += 100;
|
||
AutoDestroyOnParticleEnd(fx);
|
||
}
|
||
|
||
// 부가 효과 (DoT·Stun·Slow·DebuffStack) — StatusApplier 위임
|
||
var enemy = other.GetComponent<EnemyController>();
|
||
if (enemy != null)
|
||
{
|
||
StatusApplier.Apply(_data, enemy);
|
||
}
|
||
|
||
// Enemy 즉사 시 EnemyDeath 체인 발동
|
||
if (!health.IsAlive && enemy != null)
|
||
{
|
||
Schedule<EnemyDeath>().enemy = enemy;
|
||
}
|
||
|
||
// 단일 적 타격 후 소멸 (관통 미지원 — Phase 2 범위 내)
|
||
SelfDestruct();
|
||
return;
|
||
}
|
||
|
||
// BT12-Dev 2026-05-10 (PD #2) — 벽 충돌 시 SelfDestruct.
|
||
// Layer 0 (Default·Ground) · Layer 16 (Foreground·발판) 영역 영역 Tilemap·Composite·Box collider 영역 정합.
|
||
// 레이저 영역 영역 영역 영역 영역 X — 본 Projectile 영역 영역 (영역 영역 영역 영역 X) — 모든 Projectile 영역 SelfDestruct.
|
||
int otherLayer = other.gameObject.layer;
|
||
bool isWall = (otherLayer == 0 || otherLayer == 16);
|
||
if (isWall)
|
||
{
|
||
SelfDestruct();
|
||
}
|
||
}
|
||
|
||
// PD 지시 2026-05-14 — 즉시 Destroy 영역 trail·glow particle 영역 즉시 영역 영역 영역 → 자연 fade out 후 Destroy
|
||
bool _fadeOutStarted = false;
|
||
protected void SelfDestruct()
|
||
{
|
||
if (_fadeOutStarted) return;
|
||
_fadeOutStarted = true;
|
||
CancelInvoke(nameof(SelfDestruct));
|
||
|
||
// 판정 종료 — Collider·자식 박스 즉시 disable·중복 hit 차단
|
||
var col = GetComponent<Collider2D>();
|
||
if (col != null) col.enabled = false;
|
||
if (_debugBoxTransform != null) _debugBoxTransform.gameObject.SetActive(false);
|
||
// 이동 정지
|
||
_speed = 0f;
|
||
|
||
// ParticleSystem 영역 emission stop·기존 particle 영역 자연 fade
|
||
foreach (var ps in GetComponentsInChildren<ParticleSystem>(true))
|
||
{
|
||
ps.Stop(true, ParticleSystemStopBehavior.StopEmitting);
|
||
}
|
||
|
||
// 0.5s 후 Destroy (기존 particle lifetime 영역 충분 영역 영역)
|
||
Destroy(gameObject, 0.5f);
|
||
}
|
||
|
||
// PD 지시 2026-05-13 — 시각화 박스 자식 reference (Update 영역 매 frame Inspector 정합 갱신용)
|
||
protected Transform _debugBoxTransform;
|
||
|
||
// PD 지시 2026-05-13 — Projectile 자체 Collider2D bounds 영역 자식 박스 부착 (이동·페이드 정합·HitboxSize 정합)
|
||
void SpawnHitboxDebugChild()
|
||
{
|
||
var col = GetComponent<Collider2D>();
|
||
if (col == null) return;
|
||
Vector2 size = (col is BoxCollider2D box) ? box.size : new Vector2(col.bounds.size.x / Mathf.Max(0.01f, Mathf.Abs(transform.lossyScale.x)),
|
||
col.bounds.size.y / Mathf.Max(0.01f, Mathf.Abs(transform.lossyScale.y)));
|
||
Vector2 offset = (col is BoxCollider2D box2) ? box2.offset : (col is CircleCollider2D cc ? cc.offset : Vector2.zero);
|
||
var go = new GameObject("ProjectileHitbox_Debug");
|
||
// PD 지시 2026-05-13 — 런타임 spawn 박스 Scene 저장 회피
|
||
go.hideFlags = HideFlags.DontSave;
|
||
go.transform.SetParent(transform, false);
|
||
go.transform.localPosition = new Vector3(offset.x, offset.y, 0f);
|
||
go.transform.localScale = new Vector3(size.x, size.y, 1f);
|
||
var sr = go.AddComponent<SpriteRenderer>();
|
||
sr.sprite = HitboxDebug.GetWhiteSprite();
|
||
sr.color = new Color(1f, 0f, 0f, 0.35f);
|
||
sr.sortingOrder = 100;
|
||
sr.enabled = HitboxDebug.ShowDebugVisuals;
|
||
_debugBoxTransform = go.transform;
|
||
}
|
||
|
||
// PD 지시 2026-05-13 — Inspector 영역 HitboxSize·ProjFxScale 변경 시 발사 중인 Projectile 도 즉시 반영.
|
||
protected void SyncHitboxToData()
|
||
{
|
||
if (_data == null) return;
|
||
// HitboxSize 영역 BoxCollider2D + 자식 박스 정합
|
||
var box = GetComponent<BoxCollider2D>();
|
||
if (box != null && box.size != _data.HitboxSize)
|
||
{
|
||
box.size = _data.HitboxSize;
|
||
}
|
||
if (_debugBoxTransform != null)
|
||
{
|
||
var s = _debugBoxTransform.localScale;
|
||
if (Mathf.Abs(s.x - _data.HitboxSize.x) > 0.001f || Mathf.Abs(s.y - _data.HitboxSize.y) > 0.001f)
|
||
{
|
||
_debugBoxTransform.localScale = new Vector3(_data.HitboxSize.x, _data.HitboxSize.y, 1f);
|
||
}
|
||
}
|
||
// PD 정합 — ProjectileFxScale 영역 _baseScale 영역 매 frame 영역 (Inspector 변경 영역 즉시)
|
||
_baseScale = _originalScale * _data.ProjectileFxScale;
|
||
}
|
||
|
||
// BT12-Dev 2026-05-13 — ParticleSystem 영역 자동 destroy. main.duration + startLifetime.constantMax 영역 영역 후 Destroy.
|
||
protected static void AutoDestroyOnParticleEnd(GameObject fxGo)
|
||
{
|
||
if (fxGo == null) return;
|
||
var ps = fxGo.GetComponent<ParticleSystem>();
|
||
if (ps == null) ps = fxGo.GetComponentInChildren<ParticleSystem>();
|
||
float lifetime = 3f; // fallback
|
||
if (ps != null)
|
||
{
|
||
var main = ps.main;
|
||
lifetime = main.duration + main.startLifetime.constantMax + 0.2f;
|
||
}
|
||
// PD 지시 2026-05-13 — unscaledTime cap (Time.timeScale=0 영역 잔존 차단)
|
||
FxAutoDestroyUnscaled.Attach(fxGo, Mathf.Min(lifetime, 5f));
|
||
}
|
||
}
|
||
}
|