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; // BT69 — Down + Jump Drop-Through (PD 명시 2026-05-08): Down 누른 상태에서 점프 시 발판 통과 + 점프 모션 private float dropThroughTimer = 0f; // 활성 시간 동안 Layer 16 mask 강제 OFF private const float DROP_THROUGH_DURATION = 0.3f; // Drop-Through 활성 지속 시간 (초) private bool dropThroughJump = false; // 본 frame Drop-Through 점프 발동 여부 (velocity.y 처리 분기) // BT72 — 점프 ascending 통과 일관성 (PD 질문 2026-05-08): 정점 영역 jitter 차단 private float jumpAscentTimer = 0f; private const float JUMP_ASCENT_DURATION = 0.4f; // 점프 시작 후 mask OFF 보장 시간 (정점 영역 jitter 차단) /*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(); // PD 지시 2026-05-07 — 동반 컴포넌트 자동 부착 (Inspector 부착 불요) if (GetComponent() == null) gameObject.AddComponent(); if (GetComponent() == null) gameObject.AddComponent(); // BT5-Dev #34 — PlatformDropThrough 폐기 (PlatformEffector2D 표준 패턴 활용) var oldDrop = GetComponent(); if (oldDrop != null) Destroy(oldDrop); // PD 지시 2026-05-07 — 사망 시 입력 차단 / 부활 시 입력 복원 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; } } // BT5-Dev #30 — 발판 GameObject 영역 식별 진단 (PD 영역 영역 영역 부딪힘 시 Console 출력) void OnCollisionEnter2D(Collision2D col) { if (col.gameObject == null) return; int layer = col.gameObject.layer; string name = col.gameObject.name; Debug.Log($"[BT30-Collide] name='{name}' layer={layer} (8=JumpThrough, 0=Default)"); } 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()) { // BT69 — Down + Jump = Drop-Through (PD 명시 2026-05-08) // BT70 — 발판(Layer 16) 위 검증 추가 (PD 보고 2026-05-08: 지면 위 Down + Jump = 점프 X 버그) // 발판 위만 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; } // BT69 — Drop-Through Timer 감소 (Layer 16 mask 강제 OFF 지속 시간) if (dropThroughTimer > 0f) dropThroughTimer -= Time.deltaTime; // BT72 — Jump Ascent Timer 감소 (점프 ascending 통과 일관성) if (jumpAscentTimer > 0f) jumpAscentTimer -= Time.deltaTime; UpdateJumpState(); base.Update(); // PD 지시 2026-05-07 — 낙사 시 복귀할 안전 위치 추적 if (IsGrounded) LastGroundedPosition = transform.position; } // BT5-Dev #40 — 개발팀장 진단: KinematicObject.Start() contactFilter 캐싱 우회 // Physics2D.IgnoreLayerCollision은 raycast contactFilter 영역 무관. SetLayerMask 직접 갱신 의무. const int JUMP_THROUGH_LAYER = 16; void UpdateJumpState() { jump = false; switch (jumpState) { case JumpState.PrepareToJump: jumpState = JumpState.Jumping; jump = true; stopJump = false; // BT72 — 점프 ascending 통과 일관성: Drop-Through 점프(velocity.y<0)는 Timer 활성 X (이미 dropThroughTimer 영역) if (!dropThroughJump) jumpAscentTimer = JUMP_ASCENT_DURATION; // BT41 — PrepareToJump frame 영역 즉시 contactFilter Layer 16 mask OFF (다음 ComputeVelocity 영역 velocity.y 적용 영역 영역 frame 영역 발판 영역 충돌 차단) { 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; } // BT40 — Drop-Through: velocity.y > 0(상승) 영역 Layer 16 mask 비활성, 그 외 활성 UpdateContactFilterForDropThrough(); } void UpdateContactFilterForDropThrough() { int baseMask = Physics2D.GetLayerCollisionMask(gameObject.layer); // BT5-Dev #43 — IsGrounded 영역 폐기 (frame 0 미설정 → 떨어짐). footHit 단독 + 점프 영역 OFF 강제 // BT69 — Drop-Through Timer 활성 시 Layer 16 mask 강제 OFF (Down + Jump 입력 발판 통과) // BT72 — Jump Ascent Timer 활성 시 mask 강제 OFF (정점 영역 jitter 차단·점프 통과 일관성) bool isJumpingThrough = jumpState == JumpState.Jumping || (jumpState == JumpState.InFlight && velocity.y > 0.01f) || dropThroughTimer > 0f || jumpAscentTimer > 0f; bool standingOnPlatform = false; if (collider2d != null && !isJumpingThrough) { // BT73 — footHit Raycast 3점 (좌·중·우) (PD 보고 2026-05-08: 발판 가장자리 jitter) // 단일 중앙 Raycast 시 가장자리 영역 검출 X·검출 O frame 교차 → 밀려남 // 좌·중·우 3점 어느 하나라도 검출 시 standingOnPlatform=true → 안정 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; // BT74 — 밀림 상태 강제 Drop-Through (PD 권고 2026-05-08: "한번이라도 밀리면 아래로 강제로 떨구어야") // 점프·낙하 중 정점 영역(velocity.y > -1.5) + 수평 입력 + 발판 가장자리 일시 검출 = 밀림 상태 // → dropThroughTimer 강제 활성 + standingOnPlatform=false 즉시 해제 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; } } // standingOnPlatform=true → Layer 16 ON (착지·서있는 영역 영역 X) / 그 외 → Layer 16 OFF (통과) int mask = standingOnPlatform ? baseMask : (baseMask & ~(1 << JUMP_THROUGH_LAYER)); contactFilter.SetLayerMask(mask); contactFilter.useLayerMask = true; } protected override void ComputeVelocity() { if (jump && IsGrounded) { // BT69 — Drop-Through 점프 분기: Down + Jump 입력 시 위로 점프 X (gravity로 떨어짐 + 점프 모션만 유지) // BT71 — velocity.y = 0 → 음수(-0.5) 정정 (PD 보고 2026-05-08: Drop-Through 후 점프키 X 버그) // 원인: velocity.y=0 시 IsGrounded=true 잔존 → jumpState=Jumping 영구 → 점프 입력 무시 // 정정: 즉시 떨어짐 → IsGrounded=false → Jumping→InFlight→착지→Grounded 정상 사이클 if (dropThroughJump) { velocity.y = -0.5f; // 즉시 낙하 시작 — 발판 통과 + IsGrounded=false 확보 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 } } }