385 lines
13 KiB
C#
385 lines
13 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
[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;
|
|
|
|
[Header("Hit Bounce")]
|
|
[SerializeField] private float _hitReactionDuration = 0.5f;
|
|
[SerializeField] private float _airborneHitYVelocity = 3f;
|
|
[SerializeField] private float _wallBounceVelocityMultiplier = 0.8f;
|
|
[SerializeField] private float _wallBounceMinXVelocity = 1f;
|
|
[SerializeField] private float _wallBounceUpwardVelocity = 1.5f;
|
|
|
|
[Header("Separation")]
|
|
[SerializeField] private float _separationRadius = 0.6f;
|
|
[SerializeField] private float _separationStrength = 2f;
|
|
[SerializeField] private LayerMask _separationLayer;
|
|
private static readonly Collider2D[] _separationBuffer = new Collider2D[16];
|
|
|
|
private Health _health;
|
|
private Rigidbody2D _rb;
|
|
private Animator _anim;
|
|
private SpriteRenderer _spriteRenderer;
|
|
private Color _originalColor;
|
|
private float _flashTimer;
|
|
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;
|
|
|
|
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;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (_health != null)
|
|
_health.OnDied -= HandleDeath;
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_flashTimer > 0f)
|
|
{
|
|
_flashTimer -= Time.deltaTime;
|
|
if (_flashTimer <= 0f && _spriteRenderer != null)
|
|
{
|
|
Debug.Log($"[Flash END] t={Time.time:F3} → revert to {_originalColor}");
|
|
_spriteRenderer.color = _originalColor;
|
|
}
|
|
}
|
|
|
|
if (_hitReactionTimer > 0f)
|
|
_hitReactionTimer -= Time.deltaTime;
|
|
}
|
|
|
|
private void FixedUpdate()
|
|
{
|
|
if (_rb != null)
|
|
{
|
|
if (_isGrabbed)
|
|
{
|
|
_rb.linearVelocity = Vector2.zero;
|
|
_rb.MovePosition(_grabTargetPosition);
|
|
_lastVelocity = Vector2.zero;
|
|
return;
|
|
}
|
|
|
|
ApplySmoothHitPositionCorrection();
|
|
ApplySeparation();
|
|
_lastVelocity = _rb.linearVelocity;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f)
|
|
{
|
|
if (_health == null || _health.IsDead) return;
|
|
|
|
_isGrabbed = false;
|
|
|
|
if (_spriteRenderer != null)
|
|
{
|
|
Debug.Log($"[Flash START] t={Time.time:F3} duration={_hitFlashDuration:F3} (current color was {_spriteRenderer.color})");
|
|
_spriteRenderer.color = _hitFlashColor;
|
|
_flashTimer = _hitFlashDuration;
|
|
}
|
|
|
|
if (_anim != null && !string.IsNullOrEmpty(hitReactionAnimationState))
|
|
_anim.Play(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})");
|
|
}
|
|
|
|
public void BeginGrab(string grabbedAnimationState, int solidMask)
|
|
{
|
|
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);
|
|
}
|
|
|
|
public void UpdateGrabPosition(Vector2 position)
|
|
{
|
|
if (!_isGrabbed || _rb == null) return;
|
|
|
|
_rb.linearVelocity = Vector2.zero;
|
|
_grabTargetPosition = GetSafeHitTargetPosition(position, _grabSolidMask);
|
|
}
|
|
|
|
public void EndGrab()
|
|
{
|
|
_isGrabbed = false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private void UpdateGroundedState(Collision2D collision)
|
|
{
|
|
for (int i = 0; i < collision.contactCount; i++)
|
|
{
|
|
if (collision.GetContact(i).normal.y > 0.5f)
|
|
{
|
|
_isGrounded = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (bouncedVelocity.y < _wallBounceUpwardVelocity)
|
|
bouncedVelocity.y = _wallBounceUpwardVelocity;
|
|
|
|
_rb.linearVelocity = bouncedVelocity;
|
|
_hitReactionTimer = _hitReactionDuration;
|
|
}
|
|
|
|
private void HandleDeath()
|
|
{
|
|
Debug.Log($"{name} 사망");
|
|
Destroy(gameObject);
|
|
}
|
|
}
|