2026-05-18 기획 수정 : 플레이어와 에너미의 body간 충돌은 없다

This commit is contained in:
2026-05-18 11:45:03 +09:00
parent 53e7f3b302
commit ea3a4fbbcc
17 changed files with 762 additions and 410 deletions

View File

@@ -25,8 +25,6 @@ public class ActionData : ScriptableObject
public bool UseInputDirection = true;
public bool PreserveYVelocity = true;
public bool StopHorizontalVelocityOnEnd = true;
public bool IgnoreCollisionDuringAction;
public LayerMask IgnoredCollisionLayers;
[Header("Hit")]
public bool HasHit = true;

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(CircleCollider2D))]
public class AttackHitbox : MonoBehaviour
{
private CircleCollider2D _collider;
private int _damage;
private Vector2 _hitVelocity;
private string _hitReactionState;
private LayerMask _targetLayer;
private readonly HashSet<IDamageable> _alreadyHit = new();
private void Awake()
{
_collider = GetComponent<CircleCollider2D>();
// The player body does not collide with enemies; this trigger is the only attack contact.
_collider.isTrigger = true;
_collider.enabled = false;
}
public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity, LayerMask targetLayer)
{
transform.localPosition = localPosition;
_collider.radius = data.Radius;
_damage = data.Damage;
_hitVelocity = hitVelocity;
_hitReactionState = data.HitReactionAnimationState;
_targetLayer = targetLayer;
_alreadyHit.Clear();
_collider.enabled = true;
// Catch enemies already inside the hitbox on the same frame it opens.
ScanImmediateOverlap();
}
public void Deactivate()
{
_collider.enabled = false;
_alreadyHit.Clear();
}
private void ScanImmediateOverlap()
{
Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, _collider.radius, _targetLayer);
foreach (var hit in hits)
TryDamage(hit);
}
private void OnTriggerEnter2D(Collider2D other) => TryDamage(other);
private void OnTriggerStay2D(Collider2D other) => TryDamage(other);
private void TryDamage(Collider2D other)
{
if ((_targetLayer.value & (1 << other.gameObject.layer)) == 0) return;
// Hurtboxes may live on child objects, while damage handling usually lives on the root.
if (!other.TryGetComponent<IDamageable>(out var target))
target = other.GetComponentInParent<IDamageable>();
if (target == null) return;
if (_alreadyHit.Contains(target)) return;
_alreadyHit.Add(target);
target.TakeDamage(_damage, _hitVelocity, _hitReactionState);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 90c25ca9d3ad4c8d9e4cc0b98ff95e12

View File

@@ -73,6 +73,7 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac
if (_anim != null && !string.IsNullOrEmpty(hitReactionAnimationState))
_anim.Play(hitReactionAnimationState);
// HitVelocity is an immediate launch/knockback velocity, not an additive force.
if (_rb != null)
{
Vector2 nextVelocity = GetHitReactionVelocity(hitVelocity);
@@ -116,6 +117,7 @@ private void OnCollisionExit2D(Collision2D collision)
private Vector2 GetHitReactionVelocity(Vector2 hitVelocity)
{
// Airborne follow-up hits pop the enemy with a fixed Y velocity for stable combos.
if (_hitReactionTimer <= 0f || _isGrounded)
return hitVelocity;
@@ -139,6 +141,7 @@ private void UpdateGroundedState(Collision2D collision)
private void BounceOffWall(Vector2 wallNormal)
{
// While in hit reaction, side-wall impacts reflect the current knockback.
Vector2 incomingVelocity = _lastVelocity.sqrMagnitude > _rb.linearVelocity.sqrMagnitude
? _lastVelocity
: _rb.linearVelocity;

View File

@@ -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;
}
}
}