795 lines
27 KiB
C#
795 lines
27 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using UnityEngine;
|
|
|
|
public class PlayerController : MonoBehaviour
|
|
{
|
|
[Header("Movement")]
|
|
[SerializeField] private float _moveSpeed = 5f;
|
|
private float _moveInputX = 0f;
|
|
|
|
[Header("Jump")]
|
|
[SerializeField] private float _jumpForce = 8f;
|
|
[SerializeField] private Transform _groundCheck;
|
|
[SerializeField] private float _groundCheckRadius = 0.1f;
|
|
[SerializeField] private LayerMask _groundLayer;
|
|
private bool _isGrounded;
|
|
|
|
[Header("WallSlide")]
|
|
[SerializeField] private Transform _wallCheckLeft;
|
|
[SerializeField] private Transform _wallCheckRight;
|
|
[SerializeField] private float _wallCheckRadius = 0.1f;
|
|
[SerializeField] private float _wallSlideSpeed = 2f;
|
|
[SerializeField] private Vector2 _wallJumpForce = new Vector2(4f, 5f);
|
|
[SerializeField] private float _wallJumpInputLockDuration = 0.15f;
|
|
private bool _isTouchingLeftWall;
|
|
private bool _isTouchingRightWall;
|
|
private bool IsTouchingWall => _isTouchingLeftWall || _isTouchingRightWall;
|
|
private int _wallDirection;
|
|
private float _inputLockTimer;
|
|
private float _facingLockTimer;
|
|
|
|
[Header("Motion")]
|
|
[SerializeField] private ComboNode _dashRootNode;
|
|
[SerializeField] private ComboNode _rollRootNode;
|
|
private readonly Dictionary<ActionData, float> _motionCooldownTimers = new();
|
|
private readonly List<ActionData> _motionCooldownKeys = new();
|
|
|
|
[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 AttackHitbox _attackHitbox;
|
|
[SerializeField] private string _idleAnimationState = "Idle";
|
|
[SerializeField] private float _bufferOpenTime = 0.1f;
|
|
[SerializeField] private float _bufferLifetime = 0.5f;
|
|
private ComboInputType? _pendingInput;
|
|
private float _pendingInputTime = -1f;
|
|
private float _attackCooldownTimer;
|
|
private ComboNode _currentNode;
|
|
private float _comboWindowTimer;
|
|
private CancellationTokenSource _attackCts;
|
|
private CancellationTokenSource _motionCts;
|
|
private CancellationTokenSource _animationSpeedCts;
|
|
private ActionData _lastAttackGizmoData;
|
|
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
|
private ActionData _lastHitData;
|
|
private Vector2 _lastHitCenter;
|
|
private float _lastHitTime = -1f;
|
|
|
|
[Header("Debug")]
|
|
[SerializeField] private bool _showAttackDebug = true;
|
|
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;
|
|
|
|
private void Awake()
|
|
{
|
|
_rb = GetComponent<Rigidbody2D>();
|
|
_anim = GetComponent<Animator>();
|
|
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
|
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
|
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;
|
|
InputManager.Instance.OnRoll_Event += OnRollInput;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (InputManager.Instance != null)
|
|
{
|
|
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;
|
|
InputManager.Instance.OnRoll_Event -= OnRollInput;
|
|
}
|
|
|
|
_attackCts?.Cancel();
|
|
_attackCts?.Dispose();
|
|
_motionCts?.Cancel();
|
|
_motionCts?.Dispose();
|
|
_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);
|
|
_isTouchingRightWall = Physics2D.OverlapCircle(_wallCheckRight.position, _wallCheckRadius, _groundLayer);
|
|
_wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0);
|
|
|
|
if (_attackCooldownTimer > 0f)
|
|
_attackCooldownTimer -= Time.fixedDeltaTime;
|
|
|
|
UpdateMotionCooldowns();
|
|
ExecuteBufferedInputIfReady();
|
|
TickComboWindow();
|
|
|
|
if (_inputLockTimer > 0f)
|
|
_inputLockTimer -= Time.fixedDeltaTime;
|
|
else
|
|
_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)
|
|
UpdateFacingFromMoveInput();
|
|
}
|
|
|
|
private void UpdateFacingFromMoveInput()
|
|
{
|
|
if (_moveInputX != 0f && _spriteRenderer != null)
|
|
_spriteRenderer.flipX = _moveInputX < 0f;
|
|
}
|
|
|
|
private void OnJumpInput()
|
|
{
|
|
if (_isGrounded)
|
|
{
|
|
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce);
|
|
}
|
|
else if (IsTouchingWall)
|
|
{
|
|
_rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y);
|
|
_inputLockTimer = _wallJumpInputLockDuration;
|
|
}
|
|
}
|
|
|
|
private void OnPunchInput() => HandleComboInput(ComboInputType.Punch);
|
|
private void OnKickInput() => HandleComboInput(ComboInputType.Kick);
|
|
private void OnDashInput() => ExecuteMotionNode(_dashRootNode);
|
|
private void OnRollInput() => ExecuteMotionNode(_rollRootNode);
|
|
|
|
private void HandleComboInput(ComboInputType input)
|
|
{
|
|
if (_attackCooldownTimer > 0f)
|
|
{
|
|
float elapsed = Time.time - _attackStartTime;
|
|
if (_lastAttackGizmoData != null && elapsed >= _bufferOpenTime)
|
|
{
|
|
_pendingInput = input;
|
|
_pendingInputTime = Time.time;
|
|
}
|
|
return;
|
|
}
|
|
|
|
ExecuteComboInput(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)
|
|
{
|
|
if (transition.Trigger != input) continue;
|
|
if (transition.Next == null || transition.Next.Action == null) continue;
|
|
|
|
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
|
|
PerformAttack(transition.Next.Action, transition.ForwardStep > 0f);
|
|
_currentNode = transition.Next;
|
|
_comboWindowTimer = transition.Next.ComboWindow;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No active combo route matched, so start from the input's root node.
|
|
ComboNode root = input switch
|
|
{
|
|
ComboInputType.Punch => _punchRootNode,
|
|
ComboInputType.Kick => _kickRootNode,
|
|
_ => null
|
|
};
|
|
if (root == null || root.Action == null) return;
|
|
|
|
PerformAttack(root.Action);
|
|
_currentNode = root;
|
|
_comboWindowTimer = root.ComboWindow;
|
|
}
|
|
|
|
private void ExecuteMotionNode(ComboNode root)
|
|
{
|
|
if (root == null || root.Action == null) return;
|
|
if (IsMotionOnCooldown(root.Action)) return;
|
|
|
|
PerformMotion(root.Action);
|
|
_currentNode = root;
|
|
_comboWindowTimer = root.ComboWindow;
|
|
}
|
|
|
|
private void UpdateMotionCooldowns()
|
|
{
|
|
if (_motionCooldownTimers.Count == 0) return;
|
|
|
|
_motionCooldownKeys.Clear();
|
|
foreach (var pair in _motionCooldownTimers)
|
|
_motionCooldownKeys.Add(pair.Key);
|
|
|
|
foreach (var action in _motionCooldownKeys)
|
|
{
|
|
float remaining = _motionCooldownTimers[action] - Time.fixedDeltaTime;
|
|
if (remaining <= 0f)
|
|
_motionCooldownTimers.Remove(action);
|
|
else
|
|
_motionCooldownTimers[action] = remaining;
|
|
}
|
|
}
|
|
|
|
private bool IsMotionOnCooldown(ActionData data)
|
|
{
|
|
return data != null && _motionCooldownTimers.ContainsKey(data);
|
|
}
|
|
|
|
private void SetMotionCooldown(ActionData data)
|
|
{
|
|
if (data == null || data.Cooldown <= 0f) return;
|
|
_motionCooldownTimers[data] = data.Cooldown;
|
|
}
|
|
|
|
private async void PerformAttack(ActionData data, bool preserveHorizontalVelocity = false)
|
|
{
|
|
_attackCts?.Cancel();
|
|
_attackCts?.Dispose();
|
|
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
|
CancellationToken token = _attackCts.Token;
|
|
|
|
_attackCooldownTimer = data.Cooldown;
|
|
_lastAttackGizmoData = data;
|
|
_attackStartTime = Time.time;
|
|
_hitFired = false;
|
|
|
|
PlayActionAnimation(data);
|
|
|
|
if (data.HasMotion)
|
|
ApplyActionVelocity(data);
|
|
|
|
LockMovementIfNeeded(data, preserveHorizontalVelocity);
|
|
LockFacingIfNeeded(data);
|
|
|
|
try
|
|
{
|
|
await HitRoutine(data, token);
|
|
}
|
|
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();
|
|
_motionCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
|
CancellationToken token = _motionCts.Token;
|
|
|
|
SetMotionCooldown(data);
|
|
FaceMotionDirection(data);
|
|
PlayActionAnimation(data);
|
|
LockMovementIfNeeded(data);
|
|
LockFacingIfNeeded(data);
|
|
|
|
bool completed = false;
|
|
try
|
|
{
|
|
await MotionRoutine(data, token);
|
|
completed = true;
|
|
}
|
|
catch (System.OperationCanceledException) { }
|
|
|
|
if (completed)
|
|
{
|
|
StopActionVelocity(data);
|
|
PlayIdleAnimation();
|
|
}
|
|
}
|
|
|
|
private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
|
|
{
|
|
float elapsed = 0f;
|
|
float duration = Mathf.Max(data.MotionDuration, 0.01f);
|
|
|
|
while (elapsed < duration)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
float normalizedTime = Mathf.Clamp01(elapsed / duration);
|
|
ApplyActionVelocity(data, normalizedTime);
|
|
|
|
if (data.ReturnToIdleOnAnimationComplete && IsActionAnimationComplete(data))
|
|
return;
|
|
|
|
await Awaitable.NextFrameAsync(token);
|
|
elapsed += Time.deltaTime;
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
float direction = GetMotionDirection(data);
|
|
float speedMultiplier = data.MotionSpeedCurve != null
|
|
? data.MotionSpeedCurve.Evaluate(normalizedTime)
|
|
: 1f;
|
|
|
|
Vector2 velocity = data.Velocity * speedMultiplier;
|
|
velocity.x *= direction;
|
|
|
|
if (data.PreserveYVelocity)
|
|
velocity.y = _rb.linearVelocity.y;
|
|
|
|
_rb.linearVelocity = velocity;
|
|
}
|
|
|
|
private void StopActionVelocity(ActionData data)
|
|
{
|
|
if (!data.StopHorizontalVelocityOnEnd) return;
|
|
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
|
}
|
|
|
|
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);
|
|
|
|
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
|
|
}
|
|
|
|
private void LockFacingIfNeeded(ActionData data)
|
|
{
|
|
if (data.CanTurnDuringAction) return;
|
|
_facingLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
|
|
}
|
|
|
|
private void PlayIdleAnimation()
|
|
{
|
|
if (_anim == null) return;
|
|
|
|
_animationSpeedCts?.Cancel();
|
|
_anim.speed = 1f;
|
|
if (!string.IsNullOrEmpty(_idleAnimationState))
|
|
_anim.Play(_idleAnimationState);
|
|
}
|
|
|
|
private void PlayActionAnimation(ActionData data)
|
|
{
|
|
if (_anim == null) return;
|
|
|
|
_animationSpeedCts?.Cancel();
|
|
_animationSpeedCts?.Dispose();
|
|
_animationSpeedCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
|
|
|
_anim.speed = GetAnimationSpeed(data, 0f);
|
|
if (!string.IsNullOrEmpty(data.AnimationState))
|
|
_anim.Play(data.AnimationState);
|
|
|
|
ApplyAnimationSpeedCurve(data, _animationSpeedCts.Token);
|
|
}
|
|
|
|
private async void ApplyAnimationSpeedCurve(ActionData data, CancellationToken token)
|
|
{
|
|
float duration = Mathf.Max(data.MotionDuration, data.HitTiming + data.HitDuration, 0.01f);
|
|
float elapsed = 0f;
|
|
|
|
try
|
|
{
|
|
while (elapsed < duration)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
float normalizedTime = Mathf.Clamp01(elapsed / duration);
|
|
_anim.speed = GetAnimationSpeed(data, normalizedTime);
|
|
|
|
await Awaitable.NextFrameAsync(token);
|
|
elapsed += Time.deltaTime;
|
|
}
|
|
}
|
|
catch (System.OperationCanceledException) { }
|
|
}
|
|
|
|
private float GetAnimationSpeed(ActionData data, float normalizedTime)
|
|
{
|
|
float curveMultiplier = data.AnimationSpeedCurve != null
|
|
? data.AnimationSpeedCurve.Evaluate(normalizedTime)
|
|
: 1f;
|
|
|
|
return Mathf.Max(data.AnimationSpeed * curveMultiplier, 0.01f);
|
|
}
|
|
|
|
private float GetMotionDirection(ActionData data)
|
|
{
|
|
if (data.UseInputDirection && _moveInputX != 0f)
|
|
return _moveInputX;
|
|
|
|
return _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
|
}
|
|
|
|
private void FaceMotionDirection(ActionData data)
|
|
{
|
|
if (_spriteRenderer == null) return;
|
|
if (!data.UseInputDirection || _moveInputX == 0f) return;
|
|
|
|
_spriteRenderer.flipX = _moveInputX < 0f;
|
|
_facingLockTimer = 0f;
|
|
}
|
|
|
|
private Vector2 GetHitVelocity(Vector2 hitVelocity)
|
|
{
|
|
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
|
hitVelocity.x *= facing;
|
|
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 void ApplyForwardStep(float distance, float duration)
|
|
{
|
|
if (distance <= 0f) return;
|
|
|
|
float safeDuration = Mathf.Max(duration, 0.02f);
|
|
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
|
float vx = facing * (distance / safeDuration);
|
|
|
|
_rb.linearVelocity = new Vector2(vx, _rb.linearVelocity.y);
|
|
_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;
|
|
|
|
float since = Time.time - _lastHitTime;
|
|
if (since < 0f || since > _hitGizmoFadeDuration) return;
|
|
|
|
float t = Mathf.Clamp01(since / _hitGizmoFadeDuration);
|
|
float alpha = Mathf.Lerp(0.85f, 0f, t);
|
|
float radius = _lastHitData.Radius;
|
|
|
|
Gizmos.color = new Color(1f, 0.2f, 0.2f, alpha * 0.35f);
|
|
Gizmos.DrawSphere(_lastHitCenter, radius);
|
|
|
|
Gizmos.color = new Color(1f, 0f, 0f, alpha);
|
|
Gizmos.DrawWireSphere(_lastHitCenter, radius);
|
|
}
|
|
|
|
private void OnDrawGizmosSelected()
|
|
{
|
|
if (_groundCheck != null)
|
|
{
|
|
Gizmos.color = _isGrounded ? Color.green : Color.red;
|
|
Gizmos.DrawWireSphere(_groundCheck.position, _groundCheckRadius);
|
|
}
|
|
if (_wallCheckLeft != null)
|
|
{
|
|
Gizmos.color = _isTouchingLeftWall ? Color.green : Color.red;
|
|
Gizmos.DrawWireSphere(_wallCheckLeft.position, _wallCheckRadius);
|
|
}
|
|
if (_wallCheckRight != null)
|
|
{
|
|
Gizmos.color = _isTouchingRightWall ? Color.green : Color.red;
|
|
Gizmos.DrawWireSphere(_wallCheckRight.position, _wallCheckRadius);
|
|
}
|
|
}
|
|
|
|
private void OnGUI()
|
|
{
|
|
if (!_showAttackDebug || _lastAttackGizmoData == null) return;
|
|
|
|
ActionData data = _lastAttackGizmoData;
|
|
float elapsed = Time.time - _attackStartTime;
|
|
|
|
string status = _hitFired
|
|
? (elapsed < data.HitTiming + Mathf.Max(data.HitDuration, 0.01f) ? "HIT" : "DONE")
|
|
: "WINDUP";
|
|
string cooldownText = _attackCooldownTimer > 0f
|
|
? $"{_attackCooldownTimer:F3}s left"
|
|
: "READY";
|
|
|
|
string bufferText = _pendingInput.HasValue
|
|
? $"<color=#ffcc00>BUFFERED: {_pendingInput.Value}</color>"
|
|
: (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-");
|
|
|
|
string info =
|
|
$"<b>{GetActionName(data)}</b> [{status}]\n" +
|
|
$"Elapsed : {elapsed:F3} s\n" +
|
|
$"HitTiming : {data.HitTiming:F3} s\n" +
|
|
$"HitDuration : {data.HitDuration:F3} s\n" +
|
|
$"Cooldown : {data.Cooldown:F3} s ({cooldownText})\n" +
|
|
$"ComboWindow : {_comboWindowTimer:F3} s\n" +
|
|
$"Buffer : {bufferText}";
|
|
|
|
GUIStyle style = new GUIStyle(GUI.skin.box)
|
|
{
|
|
fontSize = 26,
|
|
alignment = TextAnchor.UpperLeft,
|
|
richText = true,
|
|
padding = new RectOffset(16, 16, 12, 12),
|
|
normal = { textColor = Color.white }
|
|
};
|
|
|
|
GUI.Box(new Rect(10, 10, 520, 282), info, style);
|
|
|
|
DrawTimelineBar(data, elapsed);
|
|
}
|
|
|
|
private void DrawTimelineBar(ActionData data, float elapsed)
|
|
{
|
|
float barX = 10f;
|
|
float barY = 302f;
|
|
float barW = 520f;
|
|
float barH = 36f;
|
|
float totalTime = Mathf.Max(
|
|
data.HitTiming + Mathf.Max(data.HitDuration, 0.05f),
|
|
data.Cooldown,
|
|
data.MotionDuration,
|
|
0.3f);
|
|
|
|
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 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);
|
|
|
|
float bufferX = barX + Mathf.Clamp01(_bufferOpenTime / totalTime) * barW;
|
|
GUI.color = new Color(0.2f, 1f, 0.4f, 0.9f);
|
|
GUI.DrawTexture(new Rect(bufferX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
|
|
|
|
float cdEndX = barX + Mathf.Clamp01(data.Cooldown / totalTime) * barW;
|
|
GUI.color = new Color(0.3f, 0.7f, 1f, 0.9f);
|
|
GUI.DrawTexture(new Rect(cdEndX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
|
|
|
|
float cursorX = barX + Mathf.Clamp01(elapsed / totalTime) * barW;
|
|
GUI.color = Color.yellow;
|
|
GUI.DrawTexture(new Rect(cursorX - 2f, barY - 4f, 4f, barH + 8f), Texture2D.whiteTexture);
|
|
|
|
GUI.color = Color.white;
|
|
}
|
|
}
|