diff --git a/Assets/01_Scenes/GameScene.unity b/Assets/01_Scenes/GameScene.unity index a28ebee..4ae1e33 100644 --- a/Assets/01_Scenes/GameScene.unity +++ b/Assets/01_Scenes/GameScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c50f5ed0cb3d1c065c2ad494e297c2bf513e712a370c1adeeb7e7c6c94afd3b1 -size 55355 +oid sha256:e035bc8aac0a5f4a5cbd74e6168f5267487a0b00d39c76c1cec90a59dad304d0 +size 56181 diff --git a/Assets/02_Scripts/Player/PlayerController.cs b/Assets/02_Scripts/Player/PlayerController.cs index 0e34287..964e5e2 100644 --- a/Assets/02_Scripts/Player/PlayerController.cs +++ b/Assets/02_Scripts/Player/PlayerController.cs @@ -1,13 +1,14 @@ using System.Collections.Generic; using System.Threading; using UnityEngine; +using UnityEngine.Serialization; // ============================================================================ // PlayerController // ---------------------------------------------------------------------------- // 플레이어 캐릭터의 모든 동작을 관리하는 중심 컨트롤러. // - Kinematic Rigidbody2D 기반 (중력/충돌 모두 코드에서 직접 처리) -// - 이동, 점프(2단), 벽 슬라이드/점프, 그라운드 파운드 +// - 이동, 점프(2단), 그라운드 파운드 // - 콤보 공격 (Punch/Kick/Grab) — ActionData + ComboNode 그래프로 정의 // - 모션 액션 (Dash/Roll/BackDash) — 단발 실행, 자체 쿨다운 // - 입력 버퍼링: 쿨다운 중에도 다음 콤보 입력 받아서 자동 실행 @@ -39,24 +40,13 @@ public class PlayerController : MonoBehaviour,IDamageable [Header("Jump")] [SerializeField] private float _jumpForce = 8f; // 점프 시 vy 값 [SerializeField] private int _maxJumpCount = 2; // 최대 점프 횟수 (지상 + 공중 점프 포함) - [SerializeField] private Transform _groundCheck; // 발 밑 그라운드 감지용 빈 오브젝트 - [SerializeField] private float _groundCheckRadius = 0.1f; // 감지 반경 + [SerializeField] private float _groundCheckDistance = 0.1f; // GroundCheck 콜라이더 하향 캐스트 거리 + [SerializeField] private float _groundCheckRayInset = 0.02f; // 벽 옆면 접촉을 피하기 위한 좌우 안쪽 여백 + [SerializeField, Range(0f, 1f)] private float _groundNormalMinY = 0.65f; // 이 값 이상 위를 보는 면만 지면으로 인정 [SerializeField] private LayerMask _groundLayer; // 지면/벽으로 취급할 레이어 private bool _isGrounded; // 현재 지면 접촉 여부 private int _jumpsUsed; // 이번 공중 체류 동안 사용한 점프 수 - // ─── 벽 슬라이드 / 벽 점프 ─────────────────────────────────────────── - [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); // 벽 점프 시 (반대방향X, +Y) 속도 - [SerializeField] private float _wallJumpInputLockDuration = 0.15f; // 벽 점프 후 좌우 입력 잠금 시간 - private bool _isTouchingLeftWall; - private bool _isTouchingRightWall; - private bool IsTouchingWall => _isTouchingLeftWall || _isTouchingRightWall; - private int _wallDirection; // 닿은 벽 방향 (-1 왼쪽, +1 오른쪽, 0 없음) private float _inputLockTimer; // 이동 입력 잠금 타이머 private float _facingLockTimer; // 페이싱 잠금 타이머 private ActionData _movementLockAction; // 애니메이션 기반 잠금 시 참조 액션 @@ -84,7 +74,12 @@ public class PlayerController : MonoBehaviour,IDamageable [Header("Kinematic Physics")] [SerializeField] private float _gravity = -25f; // 중력 가속도 (units/sec^2, 음수) [SerializeField] private float _maxFallSpeed = 20f; // 낙하 최대 속도 클램프 - [SerializeField] private float _skinWidth = 0.02f; // 충돌 캐스트의 안전 마진 + [SerializeField] private Collider2D _groundCheckCollider; // 지면 판정 전용 콜라이더 (trigger여도 됨) + + [FormerlySerializedAs("_wallCheckCollider")] + [SerializeField] private Collider2D _bodyCollider; + [FormerlySerializedAs("_wallCheckDistance")] + [SerializeField] private float _collisionSkinWidth = 0.02f; // ─── 공격 (펀치/킥/잡기 콤보 시스템) ────────────────────────────────── [Header("Attack")] @@ -121,7 +116,6 @@ public class PlayerController : MonoBehaviour,IDamageable private bool _hitFired; // 현재 액션에서 hit이 이미 발화됐는지 private readonly List _castResults = new(); // Cast 결과 버퍼 (GC 회피용) - private Collider2D[] _bodyColliders; // 캐릭터 몸체 콜라이더들 (cast 대상) private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; // 좌우 반전 + flipX 페이싱용 @@ -132,14 +126,29 @@ private void Awake() _rb = GetComponent(); _anim = GetComponent(); _spriteRenderer = GetComponentInChildren(); - _bodyColliders = GetComponentsInChildren(); + ResolveBodyColliderReference(); EnsureAttackHitbox(); // 공격이 enemy를 hit하면 _lastHitEnemy를 기억 → 다음 잡기에서 그 enemy를 우선 타겟팅. _attackHitbox.OnHit += OnAttackHit; } - // InputManager의 이벤트들에 액션별 핸들러를 구독. - // (Awake에서 안 하는 이유: InputManager.Instance가 자기 Awake에서 세팅되어 Start 시점에 보장됨) + private void ResolveBodyColliderReference() + { + if (_bodyCollider != null && _bodyCollider.enabled) return; + + Collider2D[] colliders = GetComponentsInChildren(true); + for (int i = 0; i < colliders.Length; i++) + { + Collider2D candidate = colliders[i]; + if (candidate == null || !candidate.enabled || candidate.isTrigger) continue; + if (candidate == _groundCheckCollider) continue; + if (candidate.GetComponent() != null) continue; + + _bodyCollider = candidate; + return; + } + } + private void Start() { InputManager.Instance.OnMove_Event += OnMoveInput; @@ -178,25 +187,18 @@ private void OnDestroy() } // 매 물리 프레임의 메인 흐름: - // 1) 충돌 상태 갱신 (지면/좌우 벽) + // 1) 충돌 상태 갱신 (지면) // 2) 점프 카운트 리셋 (지면 + 낙하 중일 때만) // 3) 쿨다운/콤보 윈도우/버퍼 처리 // 4) 좌우 이동 입력을 velocity로 반영 (잠금/액션 중일 땐 스킵) // 5) 페이싱 갱신 // 6) 자체 중력 적용 - // 7) 벽 슬라이드 시 낙하 속도 클램프 - // 8) Cast 기반으로 땅/벽 침투 방지 - // 9) Locomotion 애니메이션 갱신 (Idle/Walk/Jump/Land) + // 7) Locomotion 애니메이션 갱신 (Idle/Walk/Jump/Land) 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); + _isGrounded = CheckGrounded(); // 지면에 닿고 점프 중이 아니면(상승 중 아님) 점프 카운트 리셋. - // vy>0 체크 안 하면 점프 직후 같은 프레임에 _isGrounded=true라 카운트가 0으로 되돌아가 무한 점프 가능. if (_isGrounded && _rb.linearVelocity.y <= 0f) _jumpsUsed = 0; @@ -214,16 +216,12 @@ private void FixedUpdate() if (!IsFacingLocked() && !IsActionActive()) UpdateFacingFromMoveInput(); + //중력적용 ApplyGravity(); - - // 벽에 매달려 낙하 중일 때 vy를 -_wallSlideSpeed로 클램프. - if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed) - _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed); - - // 플레이어와 적 몸체는 물리 충돌하지 않고, 땅/벽만 캐스트로 이동을 막는다. - ClampVelocityToGround(); + ResolveKinematicCollisions(); UpdateLocomotionAnimation(); + } // 쿨다운 직전에 버퍼된 콤보 입력을, 쿨다운이 풀리는 순간 자동 발화. @@ -265,20 +263,15 @@ private void UpdateFacingFromMoveInput() _spriteRenderer.flipX = _moveInputX < 0f; } - // 점프 우선순위: 지상 점프 > 벽 점프 > 공중(2단) 점프 + // 점프 우선순위: 지상 점프 > 공중(2단) 점프 private void OnJumpInput() { + Debug.Log($"점프입력 _isGrounded: {_isGrounded}"); + if (_isGrounded) { PerformJump(); } - else if (IsTouchingWall) - { - // 벽 반대 방향으로 튕겨나가는 벽 점프. 벽 점프는 _jumpsUsed 카운트 영향 없음. - _rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y); - // 잠시 좌우 입력을 잠가서 즉시 같은 벽으로 돌아붙는 걸 방지. - _inputLockTimer = _wallJumpInputLockDuration; - } else if (_jumpsUsed < _maxJumpCount) { // 공중 점프 (2단/3단). _maxJumpCount=2면 지상점프 + 공중 1회 가능. @@ -288,6 +281,7 @@ private void OnJumpInput() private void PerformJump() { + Debug.Log($"_jumpForce : {_jumpForce}, _jumpsUsed : {_jumpsUsed}"); _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce); _jumpsUsed++; } @@ -1227,8 +1221,8 @@ private void ApplyGravity() if (_isGrounded && vy <= 0f) { - if (vy != 0f) - _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, 0f); + float groundedFallSpeed = Mathf.Max(_gravity * Time.fixedDeltaTime, -_maxFallSpeed); + _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, groundedFallSpeed); return; } @@ -1236,73 +1230,141 @@ private void ApplyGravity() _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, newY); } - private void ClampVelocityToGround() + private bool CheckGrounded() + { + return TryGetGroundHit(Mathf.Max(_groundCheckDistance, 0f), out _); + } + + private void ResolveKinematicCollisions() { int solidMask = _groundLayer.value; if (solidMask == 0) return; + if (_bodyCollider == null || !_bodyCollider.enabled) return; + + float dt = Time.fixedDeltaTime; + if (dt <= 0f) return; Vector2 velocity = _rb.linearVelocity; - bool changed = false; - if (Mathf.Abs(velocity.x) > 0.001f) + velocity.x = ResolveAxisVelocity(velocity.x, Vector2.right * Mathf.Sign(velocity.x), solidMask, dt, out _); + + float originalY = velocity.y; + velocity.y = ResolveAxisVelocity(velocity.y, Vector2.up * Mathf.Sign(velocity.y), solidMask, dt, out bool blockedY); + + if (blockedY && originalY <= 0f) { - 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; - } + _isGrounded = true; + _jumpsUsed = 0; } - 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; + _rb.linearVelocity = velocity; } - private float GetClosestHitDistance(Vector2 direction, float distance, int layerMask) + private float ResolveAxisVelocity(float axisVelocity, Vector2 direction, int solidMask, float dt, out bool blocked) { - if (_bodyColliders == null || _bodyColliders.Length == 0) - _bodyColliders = GetComponentsInChildren(); + blocked = false; - ContactFilter2D filter = new ContactFilter2D + if (Mathf.Abs(axisVelocity) <= 0.001f) return axisVelocity; + + float skinWidth = Mathf.Max(_collisionSkinWidth, 0f); + float moveDistance = Mathf.Abs(axisVelocity) * dt; + float castDistance = moveDistance + skinWidth; + + if (!TryGetBodyHit(direction, castDistance, solidMask, out RaycastHit2D hit)) + return axisVelocity; + + blocked = true; + float allowedDistance = Mathf.Max(hit.distance - skinWidth, 0f); + return Mathf.Sign(axisVelocity) * (allowedDistance / dt); + } + + private bool TryGetBodyHit(Vector2 direction, float castDistance, int solidMask, out RaycastHit2D closestHit) + { + closestHit = default; + + ContactFilter2D filter = CreateSolidContactFilter(solidMask); + float closestDistance = float.PositiveInfinity; + bool hasHit = false; + + _castResults.Clear(); + int hitCount = _bodyCollider.Cast(direction, filter, _castResults, castDistance); + for (int i = 0; i < hitCount; i++) + { + RaycastHit2D hit = _castResults[i]; + if (!IsBodyBlockingHit(direction, hit)) continue; + if (hit.distance >= closestDistance) continue; + + closestDistance = hit.distance; + closestHit = hit; + hasHit = true; + } + + return hasHit; + } + + private bool IsBodyBlockingHit(Vector2 direction, RaycastHit2D hit) + { + if (Mathf.Abs(direction.x) > 0f) + { + return direction.x > 0f + ? hit.normal.x < -0.1f + : hit.normal.x > 0.1f; + } + + if (direction.y > 0f) + return hit.normal.y < -0.1f; + + return hit.normal.y >= _groundNormalMinY; + } + + private bool TryGetGroundHit(float checkDistance, out RaycastHit2D closestHit) + { + closestHit = default; + + int solidMask = _groundLayer.value; + if (solidMask == 0) return false; + if (_groundCheckCollider == null || !_groundCheckCollider.enabled) return false; + + ContactFilter2D filter = CreateSolidContactFilter(solidMask); + Bounds bounds = _groundCheckCollider.bounds; + int rayCount = 3; + float inset = Mathf.Min(Mathf.Max(_groundCheckRayInset, 0f), bounds.extents.x); + float leftX = bounds.min.x + inset; + float rightX = bounds.max.x - inset; + float originY = bounds.min.y; + float closestDistance = float.PositiveInfinity; + bool hasHit = false; + + for (int i = 0; i < rayCount; i++) + { + float t = rayCount == 1 ? 0.5f : i / (float)(rayCount - 1); + Vector2 origin = new Vector2(Mathf.Lerp(leftX, rightX, t), originY); + + _castResults.Clear(); + int hitCount = Physics2D.Raycast(origin, Vector2.down, filter, _castResults, checkDistance); + for (int j = 0; j < hitCount; j++) + { + RaycastHit2D hit = _castResults[j]; + if (hit.normal.y < _groundNormalMinY) continue; + if (hit.distance >= closestDistance) continue; + + closestDistance = hit.distance; + closestHit = hit; + hasHit = true; + } + } + + return hasHit; + } + + private static ContactFilter2D CreateSolidContactFilter(int layerMask) + { + return new ContactFilter2D { useLayerMask = true, layerMask = layerMask, useTriggers = false }; - - // 빠른 이동이 지형을 뚫지 않도록 트리거가 아닌 몸체 콜라이더를 전부 캐스트한다. - 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() @@ -1325,20 +1387,22 @@ private void OnDrawGizmos() private void OnDrawGizmosSelected() { - if (_groundCheck != null) + if (_groundCheckCollider == null || !_groundCheckCollider.enabled) return; + + Gizmos.color = _isGrounded ? Color.green : Color.red; + Bounds bounds = _groundCheckCollider.bounds; + int rayCount = Mathf.Max(3, 1); + float inset = Mathf.Min(Mathf.Max(_groundCheckRayInset, 0f), bounds.extents.x); + float leftX = bounds.min.x + inset; + float rightX = bounds.max.x - inset; + float bottomY = bounds.min.y; + float checkY = bottomY - Mathf.Max(_groundCheckDistance, 0f); + + for (int i = 0; i < rayCount; i++) { - 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); + float t = rayCount == 1 ? 0.5f : i / (float)(rayCount - 1); + float x = Mathf.Lerp(leftX, rightX, t); + Gizmos.DrawLine(new Vector3(x, bottomY, 0f), new Vector3(x, checkY, 0f)); } } diff --git a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab index b0e01a1..d1888aa 100644 --- a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab +++ b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be0a74aae046fa8561a2265073bde82fc2713673fde7bc11a3237d96e834000a -size 12263 +oid sha256:c69a84958ffc64e4492a354e12d7e6535b92382d24b676412e502d62437436ac +size 12528