EerieVillage/Assets/Scripts/Mechanics/PlayerController.cs

340 lines
16 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
public class PlayerController : KinematicObject
{
public AudioClip jumpAudio;
public AudioClip respawnAudio;
public AudioClip ouchAudio;
/// <summary>
/// Attack sound effect. 자동 발동 시 PlayerAttack.Execute가 PlayOneShot으로 재생.
/// BT7-Plan 2026-04-24 — VS 순수형 자동 발동 전환 후에도 audio hook 유지.
/// </summary>
public AudioClip attackAudio;
/// <summary>
/// Max horizontal speed of the player.
/// </summary>
public float maxSpeed = 7;
/// <summary>
/// Initial jump velocity at the start of a jump.
/// </summary>
public float jumpTakeOffSpeed = 7;
/// <summary>
/// Attack hitbox component (자동 GetComponent, 없으면 null — PlayerAttack.Execute에서 null 체크).
/// 공격은 PlayerAttackTicker가 주기적으로 Schedule<PlayerAttack>을 발화하여 실행.
/// </summary>
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<PlatformerModel>();
private InputAction m_MoveAction;
private InputAction m_JumpAction;
// 현재 facing 방향 (마지막 이동 입력 기반, 정지 시 이전 값 유지).
// BT7-Plan 2026-04-24 — PlayerAttackTicker가 자동 발동 시 참조하므로 public 노출.
Vector2 facing = Vector2.right;
/// <summary>현재 플레이어 facing 방향. PlayerAttackTicker가 Schedule 시점에 참조.</summary>
public Vector2 Facing => facing;
public Bounds Bounds => collider2d.bounds;
/// <summary>
/// 마지막 grounded 위치 — 낙사 시 안전 복귀 영역 (PD 지시 2026-05-07).
/// </summary>
public Vector3 LastGroundedPosition { get; private set; }
void Awake()
{
health = GetComponent<Health>();
audioSource = GetComponent<AudioSource>();
collider2d = GetComponent<Collider2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
if (spriteRenderer == null) spriteRenderer = GetComponentInChildren<SpriteRenderer>();
animator = GetComponent<Animator>();
if (animator == null) animator = GetComponentInChildren<Animator>();
// PD 지시 2026-05-07 — 동반 컴포넌트 자동 부착 (Inspector 부착 불요)
if (GetComponent<PlayerInvulnerabilityFlash>() == null) gameObject.AddComponent<PlayerInvulnerabilityFlash>();
if (GetComponent<Platformer.UI.ResurrectPromptUI>() == null) gameObject.AddComponent<Platformer.UI.ResurrectPromptUI>();
// BT5-Dev #34 — PlatformDropThrough 폐기 (PlatformEffector2D 표준 패턴 활용)
var oldDrop = GetComponent<PlatformDropThrough>();
if (oldDrop != null) Destroy(oldDrop);
// PD 지시 2026-05-07 — 사망 시 입력 차단 / 부활 시 입력 복원
if (health != null)
{
health.OnDeathEvent += OnHealthDeath;
health.OnResurrectEvent += OnHealthResurrect;
}
if (attackHitbox == null) attackHitbox = GetComponent<AttackHitbox>();
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<Vector2>();
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<PlayerStopJump>().player = this;
}
// 공격은 PlayerAttackTicker가 자동 주기로 Schedule<PlayerAttack>을 발화 (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<PlayerJumped>().player = this;
jumpState = JumpState.InFlight;
}
break;
case JumpState.InFlight:
if (IsGrounded)
{
Schedule<PlayerLanded>().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
}
}
}