2026-05-18 기획 수정 : 플레이어와 에너미의 body간 충돌은 없다
This commit is contained in:
@@ -34,24 +34,17 @@ public class PlayerController : MonoBehaviour
|
||||
[SerializeField] private ComboNode _rollRootNode;
|
||||
private readonly Dictionary<ActionData, float> _motionCooldownTimers = new();
|
||||
private readonly List<ActionData> _motionCooldownKeys = new();
|
||||
private readonly List<IgnoredLayerCollision> _ignoredLayerCollisions = new();
|
||||
private readonly List<Collider2D> _overlapResults = new();
|
||||
private readonly List<RaycastHit2D> _castResults = new();
|
||||
private int _activeCollisionRecoveryLayerMask;
|
||||
private Collider2D[] _bodyColliders;
|
||||
private CancellationTokenSource _restoreCollisionCts;
|
||||
private CancellationTokenSource _motionCts;
|
||||
|
||||
[Header("Collision Recovery")]
|
||||
[SerializeField] private float _overlapRecoverySpeed = 8f;
|
||||
[SerializeField] private float _overlapRecoveryOtherBodyRatio = 0.5f;
|
||||
[SerializeField] private float _bodyBlockCastDistance = 0.08f;
|
||||
[Header("Kinematic Physics")]
|
||||
[SerializeField] private float _gravity = -25f;
|
||||
[SerializeField] private float _maxFallSpeed = 20f;
|
||||
[SerializeField] private float _skinWidth = 0.02f;
|
||||
|
||||
[Header("Attack")]
|
||||
[SerializeField] private ComboNode _punchRootNode;
|
||||
[SerializeField] private ComboNode _kickRootNode;
|
||||
[SerializeField] private LayerMask _enemyLayer;
|
||||
[SerializeField] private LayerMask _bodyCollisionIgnoredLayers;
|
||||
[SerializeField] private AttackHitbox _attackHitbox;
|
||||
[SerializeField] private string _idleAnimationState = "Idle";
|
||||
[SerializeField] private float _bufferOpenTime = 0.1f;
|
||||
[SerializeField] private float _bufferLifetime = 0.5f;
|
||||
@@ -61,9 +54,9 @@ public class PlayerController : MonoBehaviour
|
||||
private ComboNode _currentNode;
|
||||
private float _comboWindowTimer;
|
||||
private CancellationTokenSource _attackCts;
|
||||
private CancellationTokenSource _motionCts;
|
||||
private CancellationTokenSource _animationSpeedCts;
|
||||
private ActionData _lastAttackGizmoData;
|
||||
private float _lastAttackGizmoTime = -1f;
|
||||
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
||||
private ActionData _lastHitData;
|
||||
private Vector2 _lastHitCenter;
|
||||
@@ -74,6 +67,8 @@ public class PlayerController : MonoBehaviour
|
||||
private float _attackStartTime = -1f;
|
||||
private bool _hitFired;
|
||||
|
||||
private readonly List<RaycastHit2D> _castResults = new();
|
||||
private Collider2D[] _bodyColliders;
|
||||
private Rigidbody2D _rb;
|
||||
private Animator _anim;
|
||||
private SpriteRenderer _spriteRenderer;
|
||||
@@ -84,14 +79,13 @@ private void Awake()
|
||||
_anim = GetComponent<Animator>();
|
||||
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
IgnoreBodyCollisions();
|
||||
EnsureAttackHitbox();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InputManager.Instance.OnMove_Event += OnMoveInput;
|
||||
InputManager.Instance.OnJump_Event += OnJumpInput;
|
||||
|
||||
InputManager.Instance.OnPunch_Event += OnPunchInput;
|
||||
InputManager.Instance.OnKick_Event += OnKickInput;
|
||||
InputManager.Instance.OnDash_Event += OnDashInput;
|
||||
@@ -112,19 +106,17 @@ private void OnDestroy()
|
||||
|
||||
_attackCts?.Cancel();
|
||||
_attackCts?.Dispose();
|
||||
_animationSpeedCts?.Cancel();
|
||||
_animationSpeedCts?.Dispose();
|
||||
_motionCts?.Cancel();
|
||||
_motionCts?.Dispose();
|
||||
_restoreCollisionCts?.Cancel();
|
||||
_restoreCollisionCts?.Dispose();
|
||||
RestoreIgnoredLayerCollisions();
|
||||
_animationSpeedCts?.Cancel();
|
||||
_animationSpeedCts?.Dispose();
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
// Sample collision probes first; movement, wall slide, and jump decisions all use these flags.
|
||||
_isGrounded = Physics2D.OverlapCircle(_groundCheck.position, _groundCheckRadius, _groundLayer);
|
||||
_isTouchingLeftWall = Physics2D.OverlapCircle(_wallCheckLeft.position, _wallCheckRadius, _groundLayer);
|
||||
_isTouchingLeftWall = Physics2D.OverlapCircle(_wallCheckLeft.position, _wallCheckRadius, _groundLayer);
|
||||
_isTouchingRightWall = Physics2D.OverlapCircle(_wallCheckRight.position, _wallCheckRadius, _groundLayer);
|
||||
_wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0);
|
||||
|
||||
@@ -132,43 +124,53 @@ private void FixedUpdate()
|
||||
_attackCooldownTimer -= Time.fixedDeltaTime;
|
||||
|
||||
UpdateMotionCooldowns();
|
||||
|
||||
if (_attackCooldownTimer <= 0f && _pendingInput.HasValue)
|
||||
{
|
||||
ComboInputType buffered = _pendingInput.Value;
|
||||
bool stillValid = Time.time - _pendingInputTime <= _bufferLifetime;
|
||||
_pendingInput = null;
|
||||
if (stillValid) ExecuteComboInput(buffered);
|
||||
}
|
||||
|
||||
if (_comboWindowTimer > 0f)
|
||||
{
|
||||
_comboWindowTimer -= Time.fixedDeltaTime;
|
||||
if (_comboWindowTimer <= 0f)
|
||||
_currentNode = null;
|
||||
}
|
||||
ExecuteBufferedInputIfReady();
|
||||
TickComboWindow();
|
||||
|
||||
if (_inputLockTimer > 0f)
|
||||
_inputLockTimer -= Time.fixedDeltaTime;
|
||||
else
|
||||
_rb.linearVelocity = new Vector2(GetBodyBlockedXVelocity(_moveInputX * _moveSpeed), _rb.linearVelocity.y);
|
||||
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
|
||||
|
||||
if (_facingLockTimer > 0f)
|
||||
_facingLockTimer -= Time.fixedDeltaTime;
|
||||
else
|
||||
UpdateFacingFromMoveInput();
|
||||
|
||||
ApplyGravity();
|
||||
|
||||
if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed)
|
||||
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed);
|
||||
|
||||
// Player and Enemy bodies do not physically collide; only ground/walls clamp player velocity.
|
||||
ClampVelocityToGround();
|
||||
}
|
||||
|
||||
private void ExecuteBufferedInputIfReady()
|
||||
{
|
||||
if (_attackCooldownTimer > 0f || !_pendingInput.HasValue) return;
|
||||
|
||||
ComboInputType buffered = _pendingInput.Value;
|
||||
bool stillValid = Time.time - _pendingInputTime <= _bufferLifetime;
|
||||
_pendingInput = null;
|
||||
if (stillValid)
|
||||
ExecuteComboInput(buffered);
|
||||
}
|
||||
|
||||
private void TickComboWindow()
|
||||
{
|
||||
if (_comboWindowTimer <= 0f) return;
|
||||
|
||||
_comboWindowTimer -= Time.fixedDeltaTime;
|
||||
if (_comboWindowTimer <= 0f)
|
||||
_currentNode = null;
|
||||
}
|
||||
|
||||
private void OnMoveInput(Vector2 value)
|
||||
{
|
||||
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
||||
|
||||
if (_facingLockTimer > 0f) return;
|
||||
|
||||
UpdateFacingFromMoveInput();
|
||||
if (_facingLockTimer <= 0f)
|
||||
UpdateFacingFromMoveInput();
|
||||
}
|
||||
|
||||
private void UpdateFacingFromMoveInput()
|
||||
@@ -213,6 +215,7 @@ private void HandleComboInput(ComboInputType input)
|
||||
|
||||
private void ExecuteComboInput(ComboInputType input)
|
||||
{
|
||||
// Continue from the current combo node while its window is open.
|
||||
if (_comboWindowTimer > 0f && _currentNode != null)
|
||||
{
|
||||
foreach (var transition in _currentNode.Transitions)
|
||||
@@ -228,10 +231,11 @@ private void ExecuteComboInput(ComboInputType input)
|
||||
}
|
||||
}
|
||||
|
||||
// No active combo route matched, so start from the input's root node.
|
||||
ComboNode root = input switch
|
||||
{
|
||||
ComboInputType.Punch => _punchRootNode,
|
||||
ComboInputType.Kick => _kickRootNode,
|
||||
ComboInputType.Kick => _kickRootNode,
|
||||
_ => null
|
||||
};
|
||||
if (root == null || root.Action == null) return;
|
||||
@@ -277,218 +281,9 @@ private bool IsMotionOnCooldown(ActionData data)
|
||||
private void SetMotionCooldown(ActionData data)
|
||||
{
|
||||
if (data == null || data.Cooldown <= 0f) return;
|
||||
|
||||
_motionCooldownTimers[data] = data.Cooldown;
|
||||
}
|
||||
|
||||
private void IgnoreBodyCollisions()
|
||||
{
|
||||
int ignoredLayers = _bodyCollisionIgnoredLayers.value;
|
||||
if (ignoredLayers == 0) return;
|
||||
|
||||
int playerLayer = gameObject.layer;
|
||||
for (int layer = 0; layer < 32; layer++)
|
||||
{
|
||||
if ((ignoredLayers & (1 << layer)) == 0) continue;
|
||||
Physics2D.IgnoreLayerCollision(playerLayer, layer, true);
|
||||
}
|
||||
}
|
||||
|
||||
private float GetBodyBlockedXVelocity(float xVelocity, ActionData action = null)
|
||||
{
|
||||
if (Mathf.Approximately(xVelocity, 0f)) return 0f;
|
||||
if (_bodyCollisionIgnoredLayers.value == 0) return xVelocity;
|
||||
if (CanPassBodyCollision(action)) return xVelocity;
|
||||
if (!IsBodyBlocked(Mathf.Sign(xVelocity))) return xVelocity;
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private bool CanPassBodyCollision(ActionData action)
|
||||
{
|
||||
return action != null
|
||||
&& action.IgnoreCollisionDuringAction
|
||||
&& (action.IgnoredCollisionLayers.value & _bodyCollisionIgnoredLayers.value) != 0;
|
||||
}
|
||||
|
||||
private bool IsBodyBlocked(float direction)
|
||||
{
|
||||
if (_bodyColliders == null || _bodyColliders.Length == 0)
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
|
||||
ContactFilter2D filter = new ContactFilter2D
|
||||
{
|
||||
useLayerMask = true,
|
||||
layerMask = _bodyCollisionIgnoredLayers,
|
||||
useTriggers = false
|
||||
};
|
||||
|
||||
Vector2 castDirection = new Vector2(direction, 0f);
|
||||
for (int i = 0; i < _bodyColliders.Length; i++)
|
||||
{
|
||||
Collider2D bodyCollider = _bodyColliders[i];
|
||||
if (bodyCollider == null || bodyCollider.isTrigger) continue;
|
||||
|
||||
_castResults.Clear();
|
||||
if (bodyCollider.Cast(castDirection, filter, _castResults, _bodyBlockCastDistance) > 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void IgnoreCollisionsIfNeeded(ActionData data)
|
||||
{
|
||||
_restoreCollisionCts?.Cancel();
|
||||
if (!data.IgnoreCollisionDuringAction)
|
||||
{
|
||||
RestoreIgnoredLayerCollisionsWhenClear();
|
||||
return;
|
||||
}
|
||||
|
||||
int playerLayer = gameObject.layer;
|
||||
int ignoredLayers = data.IgnoredCollisionLayers.value;
|
||||
_activeCollisionRecoveryLayerMask = ignoredLayers;
|
||||
for (int layer = 0; layer < 32; layer++)
|
||||
{
|
||||
if ((ignoredLayers & (1 << layer)) == 0) continue;
|
||||
if (Physics2D.GetIgnoreLayerCollision(playerLayer, layer)) continue;
|
||||
|
||||
Physics2D.IgnoreLayerCollision(playerLayer, layer, true);
|
||||
_ignoredLayerCollisions.Add(new IgnoredLayerCollision(playerLayer, layer));
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreIgnoredLayerCollisions()
|
||||
{
|
||||
for (int i = 0; i < _ignoredLayerCollisions.Count; i++)
|
||||
{
|
||||
IgnoredLayerCollision ignored = _ignoredLayerCollisions[i];
|
||||
Physics2D.IgnoreLayerCollision(ignored.LayerA, ignored.LayerB, false);
|
||||
}
|
||||
|
||||
_ignoredLayerCollisions.Clear();
|
||||
_activeCollisionRecoveryLayerMask = 0;
|
||||
}
|
||||
|
||||
private void RestoreIgnoredLayerCollisionsWhenClear(LayerMask ignoredLayers = default)
|
||||
{
|
||||
int recoveryLayerMask = ignoredLayers.value != 0 ? ignoredLayers.value : _activeCollisionRecoveryLayerMask;
|
||||
if (_ignoredLayerCollisions.Count == 0 && recoveryLayerMask == 0) return;
|
||||
|
||||
_restoreCollisionCts?.Cancel();
|
||||
_restoreCollisionCts?.Dispose();
|
||||
_restoreCollisionCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
WaitUntilClearThenRestoreCollisions(recoveryLayerMask, _restoreCollisionCts.Token);
|
||||
}
|
||||
|
||||
private async void WaitUntilClearThenRestoreCollisions(int recoveryLayerMask, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (IsOverlappingIgnoredLayers(recoveryLayerMask))
|
||||
{
|
||||
ResolveIgnoredLayerOverlap(recoveryLayerMask);
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreIgnoredLayerCollisions();
|
||||
}
|
||||
|
||||
private void ResolveIgnoredLayerOverlap(int layerMask)
|
||||
{
|
||||
if (layerMask == 0) return;
|
||||
|
||||
if (_bodyColliders == null || _bodyColliders.Length == 0)
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
|
||||
ContactFilter2D filter = new ContactFilter2D
|
||||
{
|
||||
useLayerMask = true,
|
||||
layerMask = layerMask,
|
||||
useTriggers = false
|
||||
};
|
||||
|
||||
Vector2 selfCenter = _rb.position;
|
||||
float distance = _overlapRecoverySpeed * Time.deltaTime;
|
||||
|
||||
for (int i = 0; i < _bodyColliders.Length; i++)
|
||||
{
|
||||
Collider2D bodyCollider = _bodyColliders[i];
|
||||
if (bodyCollider == null || bodyCollider.isTrigger) continue;
|
||||
|
||||
_overlapResults.Clear();
|
||||
bodyCollider.Overlap(filter, _overlapResults);
|
||||
|
||||
for (int j = 0; j < _overlapResults.Count; j++)
|
||||
{
|
||||
Collider2D other = _overlapResults[j];
|
||||
if (other == null) continue;
|
||||
|
||||
Vector2 pushDirection = selfCenter - (Vector2)other.bounds.center;
|
||||
if (pushDirection.sqrMagnitude < 0.0001f)
|
||||
pushDirection = GetFacingDirection();
|
||||
pushDirection.Normalize();
|
||||
|
||||
Rigidbody2D otherRb = other.attachedRigidbody;
|
||||
bool canMoveOther = otherRb != null && otherRb != _rb && otherRb.bodyType != RigidbodyType2D.Static;
|
||||
float otherDistance = canMoveOther ? distance * _overlapRecoveryOtherBodyRatio : 0f;
|
||||
float selfDistance = distance - otherDistance;
|
||||
|
||||
_rb.position += pushDirection * selfDistance;
|
||||
if (canMoveOther)
|
||||
otherRb.position -= pushDirection * otherDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 GetFacingDirection()
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
return new Vector2(facing, 0f);
|
||||
}
|
||||
|
||||
private bool IsOverlappingIgnoredLayers(int layerMask)
|
||||
{
|
||||
if (layerMask == 0) return false;
|
||||
|
||||
if (_bodyColliders == null || _bodyColliders.Length == 0)
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
|
||||
ContactFilter2D filter = new ContactFilter2D
|
||||
{
|
||||
useLayerMask = true,
|
||||
layerMask = layerMask,
|
||||
useTriggers = false
|
||||
};
|
||||
|
||||
for (int i = 0; i < _bodyColliders.Length; i++)
|
||||
{
|
||||
Collider2D bodyCollider = _bodyColliders[i];
|
||||
if (bodyCollider == null || bodyCollider.isTrigger) continue;
|
||||
|
||||
_overlapResults.Clear();
|
||||
if (bodyCollider.Overlap(filter, _overlapResults) > 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private int GetCurrentIgnoredLayerMask()
|
||||
{
|
||||
int layerMask = 0;
|
||||
for (int i = 0; i < _ignoredLayerCollisions.Count; i++)
|
||||
layerMask |= 1 << _ignoredLayerCollisions[i].LayerB;
|
||||
|
||||
return layerMask;
|
||||
}
|
||||
|
||||
private async void PerformAttack(ActionData data, bool preserveHorizontalVelocity = false)
|
||||
{
|
||||
_attackCts?.Cancel();
|
||||
@@ -504,25 +299,26 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
|
||||
PlayActionAnimation(data);
|
||||
|
||||
if (data.HasMotion)
|
||||
{
|
||||
ApplyActionVelocity(data);
|
||||
}
|
||||
|
||||
LockMovementIfNeeded(data, preserveHorizontalVelocity);
|
||||
LockFacingIfNeeded(data);
|
||||
IgnoreCollisionsIfNeeded(data);
|
||||
|
||||
try
|
||||
{
|
||||
await HitRoutine(data, token);
|
||||
}
|
||||
catch (System.OperationCanceledException) { }
|
||||
RestoreIgnoredLayerCollisionsWhenClear(data.IgnoredCollisionLayers);
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
_attackHitbox?.Deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
private async void PerformMotion(ActionData data)
|
||||
{
|
||||
if (data == null || IsMotionOnCooldown(data)) return;
|
||||
|
||||
// Motions such as dash/roll interrupt attacks and become the new combo node.
|
||||
CancelAttack();
|
||||
_motionCts?.Cancel();
|
||||
_motionCts?.Dispose();
|
||||
@@ -530,13 +326,10 @@ private async void PerformMotion(ActionData data)
|
||||
CancellationToken token = _motionCts.Token;
|
||||
|
||||
SetMotionCooldown(data);
|
||||
|
||||
FaceMotionDirection(data);
|
||||
PlayActionAnimation(data);
|
||||
|
||||
LockMovementIfNeeded(data);
|
||||
LockFacingIfNeeded(data);
|
||||
IgnoreCollisionsIfNeeded(data);
|
||||
|
||||
bool completed = false;
|
||||
try
|
||||
@@ -551,7 +344,6 @@ private async void PerformMotion(ActionData data)
|
||||
StopActionVelocity(data);
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
RestoreIgnoredLayerCollisionsWhenClear(data.IgnoredCollisionLayers);
|
||||
}
|
||||
|
||||
private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
|
||||
@@ -562,7 +354,6 @@ private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
|
||||
while (elapsed < duration)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
float normalizedTime = Mathf.Clamp01(elapsed / duration);
|
||||
ApplyActionVelocity(data, normalizedTime);
|
||||
|
||||
@@ -579,6 +370,79 @@ private void CancelAttack()
|
||||
_attackCts?.Cancel();
|
||||
_attackCooldownTimer = 0f;
|
||||
_pendingInput = null;
|
||||
_attackHitbox?.Deactivate();
|
||||
}
|
||||
|
||||
private async Awaitable HitRoutine(ActionData data, CancellationToken token)
|
||||
{
|
||||
float attackStartTime = Time.time;
|
||||
|
||||
if (!data.HasHit)
|
||||
{
|
||||
if (data.MotionDuration > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(data.MotionDuration, token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.HitTiming > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(data.HitTiming, token);
|
||||
|
||||
// Hit windows are represented by a real trigger collider instead of overlap checks.
|
||||
ActivateAttackHitbox(data);
|
||||
|
||||
float activeTime = Mathf.Max(data.HitDuration, 0.02f);
|
||||
await Awaitable.WaitForSecondsAsync(activeTime, token);
|
||||
_attackHitbox.Deactivate();
|
||||
|
||||
float remaining = data.MotionDuration - (Time.time - attackStartTime);
|
||||
if (remaining > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(remaining, token);
|
||||
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
|
||||
private void ActivateAttackHitbox(ActionData data)
|
||||
{
|
||||
Vector2 localPosition = GetAttackLocalPosition(data.Offset);
|
||||
Vector2 hitVelocity = GetHitVelocity(data.HitVelocity);
|
||||
|
||||
_lastHitData = data;
|
||||
_lastHitCenter = transform.TransformPoint(localPosition);
|
||||
_lastHitTime = Time.time;
|
||||
_hitFired = true;
|
||||
|
||||
_attackHitbox.Activate(data, localPosition, hitVelocity, _enemyLayer);
|
||||
}
|
||||
|
||||
private void EnsureAttackHitbox()
|
||||
{
|
||||
// Allow designers to assign a hitbox, but create one automatically for simple setup.
|
||||
if (_attackHitbox != null)
|
||||
{
|
||||
SetAttackHitboxLayer();
|
||||
return;
|
||||
}
|
||||
|
||||
_attackHitbox = GetComponentInChildren<AttackHitbox>(true);
|
||||
if (_attackHitbox != null)
|
||||
{
|
||||
SetAttackHitboxLayer();
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject hitboxObject = new GameObject("AttackHitbox");
|
||||
hitboxObject.transform.SetParent(transform, false);
|
||||
hitboxObject.AddComponent<CircleCollider2D>();
|
||||
_attackHitbox = hitboxObject.AddComponent<AttackHitbox>();
|
||||
SetAttackHitboxLayer();
|
||||
}
|
||||
|
||||
private void SetAttackHitboxLayer()
|
||||
{
|
||||
// The hitbox must not inherit the Player layer, because Player vs Enemy physics is disabled.
|
||||
int defaultLayer = LayerMask.NameToLayer("Default");
|
||||
if (defaultLayer >= 0)
|
||||
_attackHitbox.gameObject.layer = defaultLayer;
|
||||
}
|
||||
|
||||
private void ApplyActionVelocity(ActionData data, float normalizedTime = 0f)
|
||||
@@ -587,9 +451,9 @@ private void ApplyActionVelocity(ActionData data, float normalizedTime = 0f)
|
||||
float speedMultiplier = data.MotionSpeedCurve != null
|
||||
? data.MotionSpeedCurve.Evaluate(normalizedTime)
|
||||
: 1f;
|
||||
|
||||
Vector2 velocity = data.Velocity * speedMultiplier;
|
||||
velocity.x *= direction;
|
||||
velocity.x = GetBodyBlockedXVelocity(velocity.x, data);
|
||||
|
||||
if (data.PreserveYVelocity)
|
||||
velocity.y = _rb.linearVelocity.y;
|
||||
@@ -600,22 +464,14 @@ private void ApplyActionVelocity(ActionData data, float normalizedTime = 0f)
|
||||
private void StopActionVelocity(ActionData data)
|
||||
{
|
||||
if (!data.StopHorizontalVelocityOnEnd) return;
|
||||
|
||||
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
||||
}
|
||||
|
||||
private bool IsActionAnimationComplete(ActionData data)
|
||||
{
|
||||
if (_anim == null || string.IsNullOrEmpty(data.AnimationState)) return false;
|
||||
|
||||
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
|
||||
return stateInfo.IsName(data.AnimationState) && stateInfo.normalizedTime >= 1f;
|
||||
}
|
||||
|
||||
private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVelocity = false)
|
||||
{
|
||||
if (data.CanMoveDuringAction) return;
|
||||
|
||||
// Non-moving attacks should not inherit walking velocity from the previous frame.
|
||||
if (!preserveHorizontalVelocity && !data.HasMotion)
|
||||
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
||||
|
||||
@@ -625,7 +481,6 @@ private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVeloci
|
||||
private void LockFacingIfNeeded(ActionData data)
|
||||
{
|
||||
if (data.CanTurnDuringAction) return;
|
||||
|
||||
_facingLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
|
||||
}
|
||||
|
||||
@@ -664,7 +519,6 @@ private async void ApplyAnimationSpeedCurve(ActionData data, CancellationToken t
|
||||
while (elapsed < duration)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
float normalizedTime = Mathf.Clamp01(elapsed / duration);
|
||||
_anim.speed = GetAnimationSpeed(data, normalizedTime);
|
||||
|
||||
@@ -701,71 +555,6 @@ private void FaceMotionDirection(ActionData data)
|
||||
_facingLockTimer = 0f;
|
||||
}
|
||||
|
||||
private async Awaitable HitRoutine(ActionData data, CancellationToken token)
|
||||
{
|
||||
float attackStartTime = Time.time;
|
||||
|
||||
if (!data.HasHit)
|
||||
{
|
||||
if (data.MotionDuration > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(data.MotionDuration, token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.HitTiming > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(data.HitTiming, token);
|
||||
|
||||
_lastAttackGizmoTime = Time.time;
|
||||
_lastHitData = data;
|
||||
_hitFired = true;
|
||||
|
||||
if (data.HitDuration <= 0f)
|
||||
{
|
||||
ApplyDamageInArea(data, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
var alreadyHit = new HashSet<IDamageable>();
|
||||
float elapsed = 0f;
|
||||
while (elapsed < data.HitDuration)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ApplyDamageInArea(data, alreadyHit);
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
elapsed += Time.deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
float remaining = data.MotionDuration - (Time.time - attackStartTime);
|
||||
if (remaining > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(remaining, token);
|
||||
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
|
||||
private void ApplyDamageInArea(ActionData data, HashSet<IDamageable> alreadyHit)
|
||||
{
|
||||
Vector2 center = GetAttackCenter(data.Offset);
|
||||
|
||||
_lastHitData = data;
|
||||
_lastHitCenter = center;
|
||||
_lastHitTime = Time.time;
|
||||
|
||||
Collider2D[] hits = Physics2D.OverlapCircleAll(center, data.Radius, _enemyLayer);
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
if (!hit.TryGetComponent<IDamageable>(out var target)) continue;
|
||||
|
||||
if (alreadyHit != null)
|
||||
{
|
||||
if (alreadyHit.Contains(target)) continue;
|
||||
alreadyHit.Add(target);
|
||||
}
|
||||
|
||||
target.TakeDamage(data.Damage, GetHitVelocity(data.HitVelocity), data.HitReactionAnimationState);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 GetHitVelocity(Vector2 hitVelocity)
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
@@ -773,19 +562,19 @@ private Vector2 GetHitVelocity(Vector2 hitVelocity)
|
||||
return hitVelocity;
|
||||
}
|
||||
|
||||
private Vector2 GetAttackLocalPosition(Vector2 offset)
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
offset.x *= facing;
|
||||
return offset;
|
||||
}
|
||||
|
||||
private string GetActionName(ActionData data)
|
||||
{
|
||||
if (data == null) return string.Empty;
|
||||
return string.IsNullOrEmpty(data.ActionName) ? data.name : data.ActionName;
|
||||
}
|
||||
|
||||
private Vector2 GetAttackCenter(Vector2 offset)
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
offset.x *= facing;
|
||||
return (Vector2)transform.position + offset;
|
||||
}
|
||||
|
||||
private void ApplyForwardStep(float distance, float duration)
|
||||
{
|
||||
if (distance <= 0f) return;
|
||||
@@ -798,6 +587,98 @@ private void ApplyForwardStep(float distance, float duration)
|
||||
_inputLockTimer = safeDuration;
|
||||
}
|
||||
|
||||
private bool IsActionAnimationComplete(ActionData data)
|
||||
{
|
||||
if (_anim == null || string.IsNullOrEmpty(data.AnimationState)) return false;
|
||||
|
||||
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
|
||||
return stateInfo.IsName(data.AnimationState) && stateInfo.normalizedTime >= 1f;
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
float vy = _rb.linearVelocity.y;
|
||||
|
||||
if (_isGrounded && vy <= 0f)
|
||||
{
|
||||
if (vy != 0f)
|
||||
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, 0f);
|
||||
return;
|
||||
}
|
||||
|
||||
float newY = Mathf.Max(vy + _gravity * Time.fixedDeltaTime, -_maxFallSpeed);
|
||||
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, newY);
|
||||
}
|
||||
|
||||
private void ClampVelocityToGround()
|
||||
{
|
||||
int solidMask = _groundLayer.value;
|
||||
if (solidMask == 0) return;
|
||||
|
||||
Vector2 velocity = _rb.linearVelocity;
|
||||
bool changed = false;
|
||||
|
||||
if (Mathf.Abs(velocity.x) > 0.001f)
|
||||
{
|
||||
float sign = Mathf.Sign(velocity.x);
|
||||
float maxDist = Mathf.Abs(velocity.x * Time.fixedDeltaTime);
|
||||
float hitDist = GetClosestHitDistance(new Vector2(sign, 0f), maxDist + _skinWidth, solidMask);
|
||||
if (hitDist < maxDist + _skinWidth)
|
||||
{
|
||||
float allowed = Mathf.Max(hitDist - _skinWidth, 0f);
|
||||
velocity.x = sign * (allowed / Time.fixedDeltaTime);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Mathf.Abs(velocity.y) > 0.001f)
|
||||
{
|
||||
float sign = Mathf.Sign(velocity.y);
|
||||
float maxDist = Mathf.Abs(velocity.y * Time.fixedDeltaTime);
|
||||
float hitDist = GetClosestHitDistance(new Vector2(0f, sign), maxDist + _skinWidth, solidMask);
|
||||
if (hitDist < maxDist + _skinWidth)
|
||||
{
|
||||
float allowed = Mathf.Max(hitDist - _skinWidth, 0f);
|
||||
velocity.y = sign * (allowed / Time.fixedDeltaTime);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
_rb.linearVelocity = velocity;
|
||||
}
|
||||
|
||||
private float GetClosestHitDistance(Vector2 direction, float distance, int layerMask)
|
||||
{
|
||||
if (_bodyColliders == null || _bodyColliders.Length == 0)
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
|
||||
ContactFilter2D filter = new ContactFilter2D
|
||||
{
|
||||
useLayerMask = true,
|
||||
layerMask = layerMask,
|
||||
useTriggers = false
|
||||
};
|
||||
|
||||
// Cast every non-trigger body collider so high-speed motion cannot tunnel into level geometry.
|
||||
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 OnDrawGizmos()
|
||||
{
|
||||
if (_lastHitData == null || _lastHitTime < 0f) return;
|
||||
@@ -833,34 +714,6 @@ private void OnDrawGizmosSelected()
|
||||
Gizmos.color = _isTouchingRightWall ? Color.green : Color.red;
|
||||
Gizmos.DrawWireSphere(_wallCheckRight.position, _wallCheckRadius);
|
||||
}
|
||||
|
||||
DrawLastAttackGizmo();
|
||||
}
|
||||
|
||||
private void DrawLastAttackGizmo()
|
||||
{
|
||||
if (_lastAttackGizmoData == null) return;
|
||||
if (!Application.isPlaying) return;
|
||||
|
||||
float elapsed = Time.time - _lastAttackGizmoTime;
|
||||
if (elapsed < 0f) return;
|
||||
|
||||
ActionData data = _lastAttackGizmoData;
|
||||
float activeDuration = Mathf.Max(data.HitDuration, 0.05f);
|
||||
float fadeDuration = 0.4f;
|
||||
float total = activeDuration + fadeDuration;
|
||||
if (elapsed > total) return;
|
||||
|
||||
float alpha = elapsed < activeDuration
|
||||
? 1f
|
||||
: 1f - (elapsed - activeDuration) / fadeDuration;
|
||||
alpha = Mathf.Clamp01(alpha);
|
||||
|
||||
Vector2 center = GetAttackCenter(data.Offset);
|
||||
Gizmos.color = new Color(1f, 0.3f, 0.3f, alpha * 0.35f);
|
||||
Gizmos.DrawSphere(center, data.Radius);
|
||||
Gizmos.color = new Color(1f, 0f, 0f, alpha);
|
||||
Gizmos.DrawWireSphere(center, data.Radius);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
@@ -919,7 +772,7 @@ private void DrawTimelineBar(ActionData data, float elapsed)
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.6f);
|
||||
GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture);
|
||||
|
||||
float hitX = barX + (data.HitTiming / totalTime) * barW;
|
||||
float hitX = barX + (data.HitTiming / totalTime) * barW;
|
||||
float hitEndX = barX + ((data.HitTiming + data.HitDuration) / totalTime) * barW;
|
||||
GUI.color = new Color(1f, 0.3f, 0.3f, 0.5f);
|
||||
GUI.DrawTexture(new Rect(hitX, barY, Mathf.Max(hitEndX - hitX, 4f), barH), Texture2D.whiteTexture);
|
||||
@@ -938,16 +791,4 @@ private void DrawTimelineBar(ActionData data, float elapsed)
|
||||
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
|
||||
private readonly struct IgnoredLayerCollision
|
||||
{
|
||||
public readonly int LayerA;
|
||||
public readonly int LayerB;
|
||||
|
||||
public IgnoredLayerCollision(int layerA, int layerB)
|
||||
{
|
||||
LayerA = layerA;
|
||||
LayerB = layerB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user