Files
WhiteMan_Unity2D/Assets/02_Scripts/Enemy/Enemy.cs

560 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using UnityEngine;
// ============================================================================
// Enemy
// ----------------------------------------------------------------------------
// 적 캐릭터의 모든 행동을 관리. 현재는 AI 이동 없음 (정지 표적).
// 책임:
// - IDamageable 구현 → 공격 받기
// - 피격 시각 효과 (색 깜빡)
// - 피격 시 넉백 및 위치 보정
// - 벽 충돌 시 반사 (튕기는 효과)
// - 다른 적과의 소프트 분리 (한 점에 겹치지 않도록)
// - 잡기 상태 처리 (플레이어가 강제로 끌고 다님)
// - 사망 처리 (Health.OnDied 이벤트로 트리거)
//
// HP는 Health 컴포넌트로 분리. Enemy는 Health.TakeDamage를 위임 호출.
// ============================================================================
[RequireComponent(typeof(Collider2D))]
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(Health))]
public class Enemy : MonoBehaviour, IDamageable
{
// ─── 피격 시각 효과 ──────────────────────────────────────────────────
[Header("Hit Feedback")]
[SerializeField] private float _hitFlashDuration = 0.1f; // 빨강 깜빡 지속 시간
[SerializeField] private Color _hitFlashColor = Color.red; // 깜빡 색상
[SerializeField] private float _hitStunDuration = 0.25f; // ActionData 값이 없을 때 쓰는 기본 경직 시간
[SerializeField] private string _hitAnimationState = "HitDamage"; // 공격 데이터에 피격 애니가 없을 때 재생할 기본 State
// ─── 넉백 / 벽 반사 파라미터 ─────────────────────────────────────────
[Header("Hit Bounce")]
[SerializeField] private float _hitReactionDuration = 0.5f; // 넉백 유효 시간 (벽 반사 가능 시간)
[SerializeField] private float _airborneHitYVelocity = 3f; // 공중에서 맞을 때 강제 Y 속도 (띄우기 효과)
[SerializeField] private float _wallBounceVelocityMultiplier = 0.8f;// 벽에 부딪힐 때 속도 감쇠
[SerializeField] private float _wallBounceMinXVelocity = 1f; // 이 값보다 느리면 반사 안 함 (작은 충돌 무시)
[SerializeField] private float _wallBounceUpwardVelocity = 1.5f; // 반사 후 최소 Y 속도 (위로 살짝 튀게)
// ─── 다른 적과의 시각적 분리 ─────────────────────────────────────────
// 같은 위치에 적이 겹쳐 보이지 않도록 살짝 옆으로 미는 힘.
[Header("Separation")]
[SerializeField] private float _separationRadius = 0.6f; // 이 거리 안의 다른 적과 분리
[SerializeField] private float _separationStrength = 2f; // 분리 강도 (units/sec)
[SerializeField] private LayerMask _separationLayer; // 검사 대상 레이어 (보통 Enemy)
private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; // OverlapCircle 결과 버퍼 (GC 회피)
// ─── 사망 시 드랍 ───────────────────────────────────────────────────
[Header("Drop")]
[SerializeField] private WeaponData _dropWeapon; // null이면 드랍 안 함
[SerializeField] private WeaponPickup _weaponPickupPrefab; // 픽업 오브젝트 프리팹 (한 종류 공유 가능)
// ─── 잡기 설정 ───────────────────────────────────────────────────────
[Header("Grab")]
[SerializeField] private bool _isGrabbable = true; // false면 플레이어가 잡을 수 없음 (보스 등)
private Health _health; // HP 컴포넌트 (별도 분리 → 플레이어도 같은 컴포넌트 재사용 가능)
private Rigidbody2D _rb;
private Animator _anim;
private SpriteRenderer _spriteRenderer;
private Color _originalColor; // hit flash 끝나면 복귀할 원본 색
private float _flashTimer; // 깜빡 남은 시간
private bool _isFlashing;
private float _hitStunTimer; // 피격 경직 남은 시간
private float _hitReactionTimer; // 넉백 유효 시간 카운트다운 (벽 반사 조건)
private bool _isGrounded;
private Vector2 _lastVelocity; // 직전 프레임 속도 (벽 충돌 시 반사 벡터 계산용)
private Collider2D[] _bodyColliders;
private readonly List<RaycastHit2D> _castResults = new();
// ─── 피격 위치 보정 (플레이어 공격이 적의 위치를 안정화) ─────────────
// 잡기/연계의 안정성을 위해, 공격이 적의 위치를 일정한 곳으로 끌어오는 기능.
private const float HitPositionSkinWidth = 0.02f;
private bool _isHitPositionCorrecting;
private bool _correctHitPositionY;
private float _hitPositionCorrectionTimer;
private float _hitPositionCorrectionDuration;
private Vector2 _hitPositionCorrectionStart;
private Vector2 _hitPositionCorrectionTarget;
// ─── 잡기 상태 ───────────────────────────────────────────────────────
private bool _isGrabbed; // 플레이어에게 잡힌 상태인지
private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표
private int _grabSolidMask; // 잡혀서 이동할 때 벽에 끼이지 않게 검사할 솔리드 레이어
// ─── 무적 ────────────────────────────────────────────────────────────
// true면 모든 피격을 무시한다 (보스 페이즈 전환 i-frame 등). SetInvulnerable로 토글.
private bool _isInvulnerable;
public bool IsDead => _health != null && _health.IsDead;
public bool IsGrabbed => _isGrabbed;
public bool IsGrabbable => _isGrabbable;
public bool IsInvulnerable => _isInvulnerable;
public bool IsInHitReaction => _hitStunTimer > 0f || _hitReactionTimer > 0f || _isHitPositionCorrecting || _flashTimer > 0f;
public bool CanUseAI => !IsDead && !_isGrabbed && !IsInHitReaction;
public event Action OnDamaged;
// 컴포넌트 캐싱 + Health.OnDied 구독 (사망 시 HandleDeath 자동 호출).
private void Awake()
{
_health = GetComponent<Health>();
_health.OnDied += HandleDeath;
_rb = GetComponent<Rigidbody2D>();
_anim = GetComponentInChildren<Animator>();
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
_bodyColliders = GetComponentsInChildren<Collider2D>();
if (_spriteRenderer != null)
_originalColor = _spriteRenderer.color;
}
// 이벤트 구독 해제 (Destroy 시 누수 방지).
private void OnDestroy()
{
if (_health != null)
_health.OnDied -= HandleDeath;
}
// 매 프레임: hit flash 타이머 + hit reaction 타이머 카운트다운.
private void Update()
{
if (_flashTimer > 0f)
{
_flashTimer -= Time.deltaTime;
if (_flashTimer <= 0f)
EndHitFlash();
}
if (_hitReactionTimer > 0f)
_hitReactionTimer -= Time.deltaTime;
if (_hitStunTimer > 0f)
_hitStunTimer -= Time.deltaTime;
}
private void LateUpdate()
{
if (_isFlashing)
ApplyHitFlashColor();
}
private void StartHitFlash()
{
if (_spriteRenderer == null || _hitFlashDuration <= 0f) return;
_flashTimer = Mathf.Max(_flashTimer, _hitFlashDuration);
_isFlashing = true;
ApplyHitFlashColor();
}
private void ApplyHitFlashColor()
{
if (_spriteRenderer != null)
_spriteRenderer.color = _hitFlashColor;
}
private void EndHitFlash()
{
_flashTimer = 0f;
_isFlashing = false;
if (_spriteRenderer != null)
_spriteRenderer.color = _originalColor;
}
private void PlayHitAnimation(string hitReactionAnimationState)
{
string stateName = !string.IsNullOrEmpty(hitReactionAnimationState)
? hitReactionAnimationState
: _hitAnimationState;
if (_anim == null || string.IsNullOrEmpty(stateName)) return;
int stateHash = Animator.StringToHash(stateName);
if (_anim.HasState(0, stateHash))
{
_anim.Play(stateHash, 0, 0f);
_anim.Update(0f);
return;
}
if (!stateName.Contains("."))
{
int baseLayerStateHash = Animator.StringToHash($"Base Layer.{stateName}");
if (_anim.HasState(0, baseLayerStateHash))
{
_anim.Play(baseLayerStateHash, 0, 0f);
_anim.Update(0f);
return;
}
}
Debug.LogWarning($"{name} 피격 애니메이션 State를 찾을 수 없습니다: {stateName}", this);
}
// 매 물리 프레임의 메인:
// - 잡힌 상태면 그랩 위치로 강제 이동
// - 그 외엔 피격 위치 보정 진행 + 분리력 적용
// - 벽 반사 계산을 위해 직전 velocity 기록
private void FixedUpdate()
{
if (_rb != null)
{
if (_isGrabbed)
{
// 잡힌 동안엔 자체 물리 무시하고 플레이어가 지정한 위치로 강제 이동.
_rb.linearVelocity = Vector2.zero;
_rb.MovePosition(_grabTargetPosition);
_lastVelocity = Vector2.zero;
return;
}
ApplySmoothHitPositionCorrection();
ApplySeparation();
_lastVelocity = _rb.linearVelocity;
}
}
// 같은 레이어의 다른 적과 너무 가까우면 옆으로 밀어 시각적으로 분리.
// 알고리즘:
// 1) OverlapCircle로 주변 적 검색
// 2) 각 적마다 거리 비례로 push 벡터 누적 (가까울수록 강하게)
// 3) 평균 방향 × Strength × deltaTime 만큼 X축으로만 밀어냄
// (Y로 밀면 공중 부양/점프 효과 생겨서 어색함)
private void ApplySeparation()
{
if (_separationRadius <= 0f || _separationStrength <= 0f) return;
if (_separationLayer.value == 0) return;
ContactFilter2D filter = new ContactFilter2D
{
useLayerMask = true,
layerMask = _separationLayer,
useTriggers = false
};
int count = Physics2D.OverlapCircle(_rb.position, _separationRadius, filter, _separationBuffer);
Vector2 push = Vector2.zero;
int contributors = 0;
for (int i = 0; i < count; i++)
{
Collider2D other = _separationBuffer[i];
if (other == null) continue;
if (other.attachedRigidbody == _rb) continue; // 자기 자신 스킵
Vector2 away = _rb.position - (Vector2)other.transform.position;
float dist = away.magnitude;
if (dist >= _separationRadius) continue; // 영향권 밖
Vector2 dir;
if (dist < 0.001f)
{
// 완전히 같은 위치인 경우 무작위 수평 방향으로 분리
dir = UnityEngine.Random.value < 0.5f ? Vector2.left : Vector2.right;
}
else
{
dir = away / dist;
}
// 가까울수록 strength가 1에 가까워짐 (멀어질수록 0).
float strength = 1f - (dist / _separationRadius);
push += dir * strength;
contributors++;
}
if (contributors == 0) return;
push /= contributors;
// X축으로만 분리. Y는 중력에 맡겨서 바운스/공중부양 방지.
_rb.position += new Vector2(push.x, 0f) * (_separationStrength * Time.fixedDeltaTime);
}
// IDamageable 구현. 데미지 처리 흐름:
// 1) 죽었으면 무시
// 2) 잡힌 상태 해제 (피격되면 잡기 풀림)
// 3) 시각 효과 (빨간 깜빡)
// 4) 피격 애니메이션 재생
// 5) 이전 넉백 속도 초기화 후 새 넉백 적용
// 6) 위치 보정 (옵션)
// 7) Health.TakeDamage로 HP 감소 → 0이면 OnDied 이벤트로 HandleDeath 트리거
public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f, float hitStunDuration = -1f)
{
if (_health == null || _health.IsDead) return;
// 무적이면 데미지/플래시/넉백 전부 무시.
if (_isInvulnerable) return;
float appliedHitStunDuration = hitStunDuration >= 0f ? hitStunDuration : _hitStunDuration;
_hitStunTimer = Mathf.Max(_hitStunTimer, appliedHitStunDuration);
OnDamaged?.Invoke();
_isGrabbed = false;
StartHitFlash();
PlayHitAnimation(hitReactionAnimationState);
// 새 피격이 반응 속도를 전부 결정하므로, 이전 튕김/넉백 속도는 먼저 제거한다.
if (_rb != null)
{
_hitReactionTimer = 0f;
_rb.linearVelocity = Vector2.zero;
_lastVelocity = Vector2.zero;
BeginHitTargetPositionCorrection(hitTargetPosition, correctHitTargetY, hitPositionSolidMask, hitPositionCorrectionDuration);
Vector2 nextVelocity = GetHitReactionVelocity(hitVelocity);
if (nextVelocity != Vector2.zero)
{
_rb.linearVelocity = nextVelocity;
_lastVelocity = nextVelocity;
_hitReactionTimer = _hitReactionDuration;
}
}
// 시각/반응 처리가 끝난 뒤 HP를 감산해서 OnDied 이벤트가 마지막에 발화되게 한다.
_health.TakeDamage(amount);
Debug.Log($"{name} 피격: -{amount} (HP: {_health.CurrentHealth}/{_health.MaxHealth})");
}
// 무적 토글. 보스 페이즈 전환 중 무적 부여 등에 사용 (Boss 컴포넌트가 호출).
public void SetInvulnerable(bool value)
{
_isInvulnerable = value;
}
// 플레이어가 잡기 액션 시작 시 호출. 이 적이 잡힘 상태로 진입.
// 이후 매 FixedUpdate에서 _grabTargetPosition으로 강제 이동됨.
public void BeginGrab(string grabbedAnimationState, int solidMask)
{
if (!_isGrabbable) return;
if (_health == null || _health.IsDead) return;
_isGrabbed = true;
_grabSolidMask = solidMask;
_isHitPositionCorrecting = false;
_hitReactionTimer = 0f;
_lastVelocity = Vector2.zero;
if (_rb != null)
{
_rb.linearVelocity = Vector2.zero;
_grabTargetPosition = _rb.position;
}
if (_anim != null && !string.IsNullOrEmpty(grabbedAnimationState))
_anim.Play(grabbedAnimationState);
}
// 플레이어가 잡힌 적을 매 프레임 이동시킬 때 호출 (예: 들어올렸다 내려치기).
// GetSafeHitTargetPosition으로 벽에 끼이지 않게 안전 위치 계산.
public void UpdateGrabPosition(Vector2 position)
{
if (!_isGrabbed || _rb == null) return;
_rb.linearVelocity = Vector2.zero;
_grabTargetPosition = GetSafeHitTargetPosition(position, _grabSolidMask);
}
// 잡기 종료. 적은 다시 자체 물리로 돌아감.
public void EndGrab()
{
_isGrabbed = false;
}
// Unity의 OnCollisionEnter2D 콜백. 두 가지 일을 처리:
// 1) 지면 상태 갱신 (착지 감지)
// 2) 넉백 중에 벽 부딪히면 반사 (튀어오르는 효과)
// 플레이어와 부딪히면 반사 안 함 (다른 적과 부딪히는 경우만).
private void OnCollisionEnter2D(Collision2D collision)
{
UpdateGroundedState(collision);
if (_hitReactionTimer <= 0f || _rb == null) return;
if (collision.collider.GetComponentInParent<PlayerController>() != null) return;
for (int i = 0; i < collision.contactCount; i++)
{
Vector2 normal = collision.GetContact(i).normal;
if (Mathf.Abs(normal.x) < 0.5f) continue; // 수평 충돌만 처리 (천장/바닥 무시)
BounceOffWall(normal);
return;
}
}
private void OnCollisionStay2D(Collision2D collision)
{
UpdateGroundedState(collision);
}
private void OnCollisionExit2D(Collision2D collision)
{
_isGrounded = false;
}
// 공중 피격은 항상 위로 띄우는 효과 적용 (격투게임 표준).
// 지상 피격은 hitVelocity 그대로 사용.
private Vector2 GetHitReactionVelocity(Vector2 hitVelocity)
{
// 공중 추가타는 고정된 세로 속도를 쓰되, 이전 물리 속도는 절대 이어받지 않는다.
Vector2 nextVelocity = hitVelocity;
if (!_isGrounded)
nextVelocity.y = _airborneHitYVelocity;
return nextVelocity;
}
private void BeginHitTargetPositionCorrection(Vector2? hitTargetPosition, bool correctHitTargetY, int solidMask, float correctionDuration)
{
_isHitPositionCorrecting = false;
if (!hitTargetPosition.HasValue || _rb == null) return;
Vector2 targetPosition = hitTargetPosition.Value;
if (!correctHitTargetY)
targetPosition.y = _rb.position.y;
targetPosition = GetSafeHitTargetPosition(targetPosition, solidMask);
if ((targetPosition - _rb.position).sqrMagnitude <= 0.0001f) return;
if (correctionDuration <= 0f)
{
_rb.position = targetPosition;
return;
}
_isHitPositionCorrecting = true;
_correctHitPositionY = correctHitTargetY;
_hitPositionCorrectionTimer = 0f;
_hitPositionCorrectionDuration = correctionDuration;
_hitPositionCorrectionStart = _rb.position;
_hitPositionCorrectionTarget = targetPosition;
}
// 매 FixedUpdate에서 진행 중인 위치 보정 보간 한 스텝 실행.
// SmoothStep으로 부드러운 진입/이탈 곡선 사용.
private void ApplySmoothHitPositionCorrection()
{
if (!_isHitPositionCorrecting || _rb == null) return;
_hitPositionCorrectionTimer += Time.fixedDeltaTime;
float normalizedTime = Mathf.Clamp01(_hitPositionCorrectionTimer / Mathf.Max(_hitPositionCorrectionDuration, Time.fixedDeltaTime));
float easedTime = Mathf.SmoothStep(0f, 1f, normalizedTime);
Vector2 nextPosition = _rb.position;
nextPosition.x = Mathf.Lerp(_hitPositionCorrectionStart.x, _hitPositionCorrectionTarget.x, easedTime);
if (_correctHitPositionY)
nextPosition.y = Mathf.Lerp(_hitPositionCorrectionStart.y, _hitPositionCorrectionTarget.y, easedTime);
_rb.MovePosition(nextPosition);
if (normalizedTime >= 1f)
_isHitPositionCorrecting = false;
}
// 목표 위치까지 cast해서 벽에 막히지 않는 최대 거리까지의 위치 반환.
// 위치 보정/잡기 이동 시 적이 벽에 끼이는 걸 방지.
private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask)
{
if (solidMask == 0) return targetPosition;
Vector2 startPosition = _rb.position;
Vector2 moveDelta = targetPosition - startPosition;
float distance = moveDelta.magnitude;
if (distance <= 0.001f) return targetPosition;
Vector2 direction = moveDelta / distance;
float closestDistance = GetClosestBodyCastDistance(direction, distance + HitPositionSkinWidth, solidMask);
if (closestDistance >= distance + HitPositionSkinWidth)
return targetPosition; // 막힘 없음 → 목표 그대로
float allowedDistance = Mathf.Max(closestDistance - HitPositionSkinWidth, 0f);
return startPosition + direction * allowedDistance; // skin만큼 떨어진 지점까지
}
// 모든 body collider를 direction 방향으로 cast해서 가장 가까운 hit 거리 반환.
// GetSafeHitTargetPosition의 헬퍼.
private float GetClosestBodyCastDistance(Vector2 direction, float distance, int solidMask)
{
if (_bodyColliders == null || _bodyColliders.Length == 0)
_bodyColliders = GetComponentsInChildren<Collider2D>();
ContactFilter2D filter = new ContactFilter2D
{
useLayerMask = true,
layerMask = solidMask,
useTriggers = false
};
float closest = float.PositiveInfinity;
for (int i = 0; i < _bodyColliders.Length; i++)
{
Collider2D bodyCollider = _bodyColliders[i];
if (bodyCollider == null || bodyCollider.isTrigger) continue;
_castResults.Clear();
int hitCount = bodyCollider.Cast(direction, filter, _castResults, distance);
for (int j = 0; j < hitCount; j++)
{
if (_castResults[j].distance < closest)
closest = _castResults[j].distance;
}
}
return closest;
}
// 접촉 노멀의 Y가 0.5보다 크면 (위쪽 방향이면) 지면 위로 판정.
// 비스듬한 경사도 어느정도 지면으로 인정.
private void UpdateGroundedState(Collision2D collision)
{
for (int i = 0; i < collision.contactCount; i++)
{
if (collision.GetContact(i).normal.y > 0.5f)
{
_isGrounded = true;
return;
}
}
}
// 피격 후 벽에 부딪힐 때 속도를 반사 (Vector2.Reflect 사용).
// _lastVelocity와 현재 velocity 중 더 큰 것을 사용하는 이유:
// - 충돌 직전 frame에 이미 velocity가 줄어들 수 있어서, 직전 값을 fallback으로 사용
// _hitReactionTimer를 다시 채워서 연쇄 반사 가능 (벽 사이를 통통 튀게).
private void BounceOffWall(Vector2 wallNormal)
{
// 피격 반응 중 옆벽에 부딪히면 현재 넉백 속도를 반사한다.
Vector2 incomingVelocity = _lastVelocity.sqrMagnitude > _rb.linearVelocity.sqrMagnitude
? _lastVelocity
: _rb.linearVelocity;
if (Mathf.Abs(incomingVelocity.x) < _wallBounceMinXVelocity) return; // 너무 느린 충돌 무시
Vector2 bouncedVelocity = Vector2.Reflect(incomingVelocity, wallNormal) * _wallBounceVelocityMultiplier;
// 반사 후 Y가 너무 낮으면 위로 튀어오르게 강제 (지면에 깔리지 않도록).
if (bouncedVelocity.y < _wallBounceUpwardVelocity)
bouncedVelocity.y = _wallBounceUpwardVelocity;
_rb.linearVelocity = bouncedVelocity;
_hitReactionTimer = _hitReactionDuration;
}
// Health.OnDied 이벤트 콜백. 사망 처리.
// _dropWeapon이 설정돼 있고 픽업 프리팹이 있으면 자기 위치에 무기 드랍.
private void HandleDeath()
{
Debug.Log($"{name} 사망");
DropWeaponIfAny();
Destroy(gameObject);
}
private void DropWeaponIfAny()
{
if (_dropWeapon == null || _weaponPickupPrefab == null) return;
WeaponPickup pickup = Instantiate(_weaponPickupPrefab, transform.position, Quaternion.identity);
pickup.Initialize(_dropWeapon);
}
}