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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.