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 _motionCooldownTimers = new(); private readonly List _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 _castResults = new(); private Collider2D[] _bodyColliders; private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; private void Awake() { _rb = GetComponent(); _anim = GetComponent(); _spriteRenderer = GetComponentInChildren(); _bodyColliders = GetComponentsInChildren(); 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(true); if (_attackHitbox != null) { SetAttackHitboxLayer(); return; } GameObject hitboxObject = new GameObject("AttackHitbox"); hitboxObject.transform.SetParent(transform, false); hitboxObject.AddComponent(); _attackHitbox = hitboxObject.AddComponent(); 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(); 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 ? $"BUFFERED: {_pendingInput.Value}" : (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-"); string info = $"{GetActionName(data)} [{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; } }