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; [Header("Attack")] [SerializeField] private ComboNode _punchRootNode; [SerializeField] private ComboNode _kickRootNode; [SerializeField] private LayerMask _enemyLayer; [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 AttackData _lastAttackGizmoData; private float _lastAttackGizmoTime = -1f; [SerializeField] private float _hitGizmoFadeDuration = 0.5f; private AttackData _lastHitData; private Vector2 _lastHitCenter; private float _lastHitTime = -1f; [Header("Debug")] [SerializeField] private bool _showAttackDebug = true; private float _attackStartTime = -1f; private bool _hitFired; private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; private void Awake() { _rb = GetComponent(); _anim = GetComponent(); _spriteRenderer = GetComponentInChildren(); } private void Start() { InputManager.Instance.OnMove_Event += OnMoveInput; InputManager.Instance.OnJump_Event += OnJumpInput; InputManager.Instance.OnPunch_Event += OnPunchInput; InputManager.Instance.OnKick_Event += OnKickInput; } 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; } _attackCts?.Cancel(); _attackCts?.Dispose(); } private void FixedUpdate() { _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; 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; } if (_inputLockTimer > 0f) _inputLockTimer -= Time.fixedDeltaTime; else _rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y); if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed) _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed); } private void OnMoveInput(Vector2 value) { _moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x); 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 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) { if (_comboWindowTimer > 0f && _currentNode != null) { foreach (var transition in _currentNode.Transitions) { if (transition.Trigger != input) continue; if (transition.Next == null || transition.Next.Attack == null) continue; ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration); PerformAttack(transition.Next.Attack); _currentNode = transition.Next; _comboWindowTimer = transition.Next.ComboWindow; return; } } ComboNode root = input switch { ComboInputType.Punch => _punchRootNode, ComboInputType.Kick => _kickRootNode, _ => null }; if (root == null || root.Attack == null) return; PerformAttack(root.Attack); _currentNode = root; _comboWindowTimer = root.ComboWindow; } private async void PerformAttack(AttackData data) { _attackCts?.Cancel(); _attackCts?.Dispose(); _attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken); CancellationToken token = _attackCts.Token; _attackCooldownTimer = data.Cooldown; _lastAttackGizmoData = data; _attackStartTime = Time.time; _hitFired = false; if (_anim != null && !string.IsNullOrEmpty(data.AnimationState)) _anim.Play(data.AnimationState); try { await HitRoutine(data, token); } catch (System.OperationCanceledException) { } } private async Awaitable HitRoutine(AttackData data, CancellationToken token) { float attackStartTime = Time.time; 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(); 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); if (_anim != null && !string.IsNullOrEmpty(_idleAnimationState)) _anim.Play(_idleAnimationState); } private void ApplyDamageInArea(AttackData data, HashSet 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(out var target)) continue; if (alreadyHit != null) { if (alreadyHit.Contains(target)) continue; alreadyHit.Add(target); } target.TakeDamage(data.Damage); } } 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; 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 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); } DrawLastAttackGizmo(); } private void DrawLastAttackGizmo() { if (_lastAttackGizmoData == null) return; if (!Application.isPlaying) return; float elapsed = Time.time - _lastAttackGizmoTime; if (elapsed < 0f) return; AttackData 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() { if (!_showAttackDebug || _lastAttackGizmoData == null) return; AttackData 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 ? $"BUFFERED: {_pendingInput.Value}" : (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-"); string info = $"{(string.IsNullOrEmpty(data.AttackName) ? data.name : data.AttackName)} [{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(AttackData 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; } }