using System.Collections; using System.Collections.Generic; using UnityEngine; using Platformer.Gameplay; using static Platformer.Core.Simulation; using Platformer.Model; using Platformer.Core; using UnityEngine.InputSystem; namespace Platformer.Mechanics { /// /// This is the main class used to implement control of the player. /// It is a superset of the AnimationController class, but is inlined to allow for any kind of customisation. /// public class PlayerController : KinematicObject { public AudioClip jumpAudio; public AudioClip respawnAudio; public AudioClip ouchAudio; /// /// Attack sound effect. 자동 발동 시 PlayerAttack.Execute가 PlayOneShot으로 재생. /// BT7-Plan 2026-04-24 — VS 순수형 자동 발동 전환 후에도 audio hook 유지. /// public AudioClip attackAudio; /// /// Max horizontal speed of the player. /// public float maxSpeed = 7; /// /// Initial jump velocity at the start of a jump. /// public float jumpTakeOffSpeed = 7; /// /// Attack hitbox component (자동 GetComponent, 없으면 null — PlayerAttack.Execute에서 null 체크). /// 공격은 PlayerAttackTicker가 주기적으로 Schedule을 발화하여 실행. /// public AttackHitbox attackHitbox; public JumpState jumpState = JumpState.Grounded; private bool stopJump; // Drop-Through (Down + Jump) — 발판 위에서 Down 유지 + 점프 시 발판 통과 + 점프 모션 + 자연 낙하 private float dropThroughTimer = 0f; private const float DROP_THROUGH_DURATION = 0.3f; private bool dropThroughJump = false; // Jump Ascent — 점프 ascending·정점 영역 mask 강제 OFF 보장 (정점 jitter 차단) private float jumpAscentTimer = 0f; private const float JUMP_ASCENT_DURATION = 0.4f; /*internal new*/ public Collider2D collider2d; /*internal new*/ public AudioSource audioSource; public Health health; public bool controlEnabled = true; bool jump; Vector2 move; SpriteRenderer spriteRenderer; internal Animator animator; readonly PlatformerModel model = Simulation.GetModel(); private InputAction m_MoveAction; private InputAction m_JumpAction; // 현재 facing 방향 (마지막 이동 입력 기반, 정지 시 이전 값 유지). // BT7-Plan 2026-04-24 — PlayerAttackTicker가 자동 발동 시 참조하므로 public 노출. Vector2 facing = Vector2.right; /// 현재 플레이어 facing 방향. PlayerAttackTicker가 Schedule 시점에 참조. public Vector2 Facing => facing; public Bounds Bounds => collider2d.bounds; /// /// 마지막 grounded 위치 — 낙사 시 안전 복귀 영역 (PD 지시 2026-05-07). /// public Vector3 LastGroundedPosition { get; private set; } void Awake() { health = GetComponent(); audioSource = GetComponent(); collider2d = GetComponent(); spriteRenderer = GetComponent(); if (spriteRenderer == null) spriteRenderer = GetComponentInChildren(); animator = GetComponent(); if (animator == null) animator = GetComponentInChildren(); // 동반 컴포넌트 자동 부착 (Inspector 부착 불요) if (GetComponent() == null) gameObject.AddComponent(); if (GetComponent() == null) gameObject.AddComponent(); // 구 PlatformDropThrough 컴포넌트 자동 제거 (Drop-Through는 ContactFilter mask 동적 갱신으로 처리) var oldDrop = GetComponent(); if (oldDrop != null) Destroy(oldDrop); // 사망 시 입력 차단 / 부활 시 입력 복원 if (health != null) { health.OnDeathEvent += OnHealthDeath; health.OnResurrectEvent += OnHealthResurrect; } if (attackHitbox == null) attackHitbox = GetComponent(); m_MoveAction = InputSystem.actions.FindAction("Player/Move"); m_JumpAction = InputSystem.actions.FindAction("Player/Jump"); m_MoveAction.Enable(); m_JumpAction.Enable(); LastGroundedPosition = transform.position; } void OnDestroy() { if (health != null) { health.OnDeathEvent -= OnHealthDeath; health.OnResurrectEvent -= OnHealthResurrect; } } void OnHealthDeath() { controlEnabled = false; move = Vector2.zero; } void OnHealthResurrect() { controlEnabled = true; } protected override void Update() { if (controlEnabled) { Vector2 moveInput = m_MoveAction.ReadValue(); move.x = moveInput.x; if (jumpState == JumpState.Grounded && m_JumpAction.WasPressedThisFrame()) { // Down + Jump 발판 위 = Drop-Through 발동 / 지면 위 = 일반 점프 bool downHeld = moveInput.y < -0.5f; bool onJumpThroughPlatform = false; if (downHeld && collider2d != null) { Vector2 dropFootPos = new Vector2(collider2d.bounds.center.x, collider2d.bounds.min.y + 0.02f); int jumpThroughMask = 1 << JUMP_THROUGH_LAYER; RaycastHit2D dropFootHit = Physics2D.Raycast(dropFootPos, Vector2.down, 0.1f, jumpThroughMask); onJumpThroughPlatform = dropFootHit.collider != null; } if (downHeld && onJumpThroughPlatform) { dropThroughTimer = DROP_THROUGH_DURATION; dropThroughJump = true; } jumpState = JumpState.PrepareToJump; } else if (m_JumpAction.WasReleasedThisFrame()) { stopJump = true; Schedule().player = this; } // 공격은 PlayerAttackTicker가 자동 주기로 Schedule을 발화 (BT7-Plan VS 순수형). } else { move.x = 0; } // Drop-Through·Jump Ascent Timer 감소 (mask 강제 OFF 지속 시간) if (dropThroughTimer > 0f) dropThroughTimer -= Time.deltaTime; if (jumpAscentTimer > 0f) jumpAscentTimer -= Time.deltaTime; UpdateJumpState(); base.Update(); // 낙사 시 복귀할 안전 위치 추적 if (IsGrounded) LastGroundedPosition = transform.position; } // Drop-Through Layer (Foreground·AutoForeground GameObject Layer 16). // KinematicObject body.Cast의 contactFilter는 Start() 시점 캐싱되므로 Drop-Through는 SetLayerMask 동적 갱신으로 처리. const int JUMP_THROUGH_LAYER = 16; void UpdateJumpState() { jump = false; switch (jumpState) { case JumpState.PrepareToJump: jumpState = JumpState.Jumping; jump = true; stopJump = false; // 일반 점프만 ascending 통과 보장 Timer 활성 (Drop-Through는 dropThroughTimer로 처리) if (!dropThroughJump) jumpAscentTimer = JUMP_ASCENT_DURATION; // PrepareToJump frame 즉시 mask OFF — 다음 frame ComputeVelocity의 velocity.y 적용 직전 발판 충돌 차단 { int baseMaskJump = Physics2D.GetLayerCollisionMask(gameObject.layer); contactFilter.SetLayerMask(baseMaskJump & ~(1 << JUMP_THROUGH_LAYER)); contactFilter.useLayerMask = true; } break; case JumpState.Jumping: if (!IsGrounded) { Schedule().player = this; jumpState = JumpState.InFlight; } break; case JumpState.InFlight: if (IsGrounded) { Schedule().player = this; jumpState = JumpState.Landed; } break; case JumpState.Landed: jumpState = JumpState.Grounded; break; } UpdateContactFilterForDropThrough(); } /// /// Drop-Through 패턴: ascending·정점·Drop-Through Timer 활성 시 Layer 16 mask OFF (통과). /// 그 외 = footHit 3점 Raycast (Layer 16) — 발판 가장자리 안정 검출. /// 점프·낙하 중 정점 영역 + 수평 입력 + 발판 가장자리 일시 검출 = 밀림 상태 → 강제 Drop-Through 발동. /// void UpdateContactFilterForDropThrough() { int baseMask = Physics2D.GetLayerCollisionMask(gameObject.layer); bool isJumpingThrough = jumpState == JumpState.Jumping || (jumpState == JumpState.InFlight && velocity.y > 0.01f) || dropThroughTimer > 0f || jumpAscentTimer > 0f; bool standingOnPlatform = false; if (collider2d != null && !isJumpingThrough) { // footHit Raycast 3점 (좌·중·우) — 가장자리 jitter 차단 float footY = collider2d.bounds.min.y + 0.02f; float boundsLeft = collider2d.bounds.min.x + 0.02f; float boundsCenter = collider2d.bounds.center.x; float boundsRight = collider2d.bounds.max.x - 0.02f; int jumpThroughMask = 1 << JUMP_THROUGH_LAYER; RaycastHit2D footHitC = Physics2D.Raycast(new Vector2(boundsCenter, footY), Vector2.down, 0.1f, jumpThroughMask); RaycastHit2D footHitL = Physics2D.Raycast(new Vector2(boundsLeft, footY), Vector2.down, 0.1f, jumpThroughMask); RaycastHit2D footHitR = Physics2D.Raycast(new Vector2(boundsRight, footY), Vector2.down, 0.1f, jumpThroughMask); standingOnPlatform = footHitC.collider != null || footHitL.collider != null || footHitR.collider != null; // 밀림 상태 강제 Drop-Through (점프·낙하 중 정점 + 수평 입력 + 발판 가장자리 일시 검출) bool inAir = jumpState == JumpState.Jumping || jumpState == JumpState.InFlight; bool nearApex = velocity.y > -1.5f; bool horizontalIntent = Mathf.Abs(move.x) > 0.1f; if (standingOnPlatform && inAir && nearApex && horizontalIntent) { dropThroughTimer = DROP_THROUGH_DURATION; standingOnPlatform = false; } } int mask = standingOnPlatform ? baseMask : (baseMask & ~(1 << JUMP_THROUGH_LAYER)); contactFilter.SetLayerMask(mask); contactFilter.useLayerMask = true; } protected override void ComputeVelocity() { if (jump && IsGrounded) { // Drop-Through 점프: 위로 점프 X (음수 velocity.y로 즉시 낙하 시작 + 점프 애니메이션만 유지) if (dropThroughJump) { velocity.y = -0.5f; dropThroughJump = false; } else { velocity.y = jumpTakeOffSpeed * model.jumpModifier; } jump = false; } else if (stopJump) { stopJump = false; if (velocity.y > 0) { velocity.y = velocity.y * model.jumpDeceleration; } } if (move.x > 0.01f) { spriteRenderer.flipX = true; facing = Vector2.right; } else if (move.x < -0.01f) { spriteRenderer.flipX = false; facing = Vector2.left; } animator.SetBool("grounded", IsGrounded); animator.SetFloat("velocityX", Mathf.Abs(velocity.x) / maxSpeed); targetVelocity = move * maxSpeed; } public enum JumpState { Grounded, PrepareToJump, Jumping, InFlight, Landed } } }