fix: 플레이어-보스 충돌 슬라이딩 및 관통 방지
- CharacterController.enableOverlapRecovery 비활성화로 자동 밀어냄 제거 - 레이어 마스크 의존 제거, 컴포넌트(NavMeshAgent/CharacterController)로 식별 - EnemyBase LateUpdate에서 velocity 기반 보스 위치 보정 - EnemyBase OnAnimatorMove에서 루트모션의 플레이어 방향 이동 차단 - BossEnemy Update를 OnServerUpdate 패턴으로 리팩터링 - 보스 프리팹 하위 오브젝트 레이어 Enemy로 통일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,6 @@ public partial class ChaseTargetAction : Action
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
// 타겟 위치로 이동
|
||||
agent.SetDestination(Target.Value.transform.position);
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
@@ -105,14 +105,11 @@ namespace Colosseum.Enemy
|
||||
customConditions.Clear();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
protected override void OnServerUpdate()
|
||||
{
|
||||
if (!IsServer || IsDead || isTransitioning)
|
||||
return;
|
||||
if (isTransitioning) return;
|
||||
|
||||
phaseElapsedTime = Time.time - phaseStartTime;
|
||||
|
||||
// 다음 페이즈 전환 조건 확인
|
||||
CheckPhaseTransition();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ namespace Colosseum.Enemy
|
||||
protected NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
protected NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
|
||||
// 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별)
|
||||
private readonly Collider[] overlapBuffer = new Collider[8];
|
||||
|
||||
// 이벤트
|
||||
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
|
||||
public event Action<float> OnDamageTaken; // damage
|
||||
@@ -65,6 +68,88 @@ namespace Colosseum.Enemy
|
||||
currentHealth.OnValueChanged += OnHealthChangedInternal;
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (!IsServer || IsDead) return;
|
||||
|
||||
OnServerUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버 Update 확장 포인트 (하위 클래스에서 override)
|
||||
/// </summary>
|
||||
protected virtual void OnServerUpdate() { }
|
||||
|
||||
/// <summary>
|
||||
/// NavMeshAgent position sync 및 OnAnimatorMove 이후에 실행됩니다.
|
||||
/// 보스가 이미 플레이어 안으로 들어온 경우 stoppingDistance 바깥으로 밀어냅니다.
|
||||
/// Update()에서의 isStopped 조작은 NavMeshAgent에 의해 덮어써지지만,
|
||||
/// LateUpdate()는 그 이후이므로 확실하게 보정됩니다.
|
||||
/// </summary>
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (!IsServer || IsDead || navMeshAgent == null) return;
|
||||
|
||||
// stoppingDistance가 0이면 radius 기반 fallback 사용
|
||||
float stopDist = navMeshAgent.stoppingDistance > 0f
|
||||
? navMeshAgent.stoppingDistance
|
||||
: navMeshAgent.radius + 0.5f;
|
||||
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, stopDist, overlapBuffer);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
// 레이어 무관하게 CharacterController 유무로 플레이어 식별
|
||||
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out _)) continue;
|
||||
|
||||
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
|
||||
toPlayer.y = 0f;
|
||||
float dist = toPlayer.magnitude;
|
||||
if (dist >= stopDist) continue;
|
||||
|
||||
// 보스가 실제로 이동 중일 때만 밀어냄.
|
||||
// isStopped는 수동 설정 시만 true가 되므로, velocity로 실제 이동 여부를 판단.
|
||||
if (navMeshAgent.velocity.sqrMagnitude > 0.01f)
|
||||
{
|
||||
Vector3 pushDir = dist > 0.001f ? -toPlayer.normalized : -transform.forward;
|
||||
navMeshAgent.Warp(transform.position + pushDir * (stopDist - dist));
|
||||
navMeshAgent.isStopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 스킬 루트모션이 플레이어 방향으로 진입하는 것을 차단합니다.
|
||||
/// </summary>
|
||||
private void OnAnimatorMove()
|
||||
{
|
||||
if (!IsServer || animator == null || navMeshAgent == null) return;
|
||||
|
||||
Vector3 deltaPosition = animator.deltaPosition;
|
||||
|
||||
float blockRadius = Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f);
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, blockRadius, overlapBuffer);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out _)) continue;
|
||||
|
||||
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
|
||||
toPlayer.y = 0f;
|
||||
if (toPlayer.sqrMagnitude < 0.0001f) continue;
|
||||
|
||||
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
|
||||
if (Vector3.Dot(deltaXZ, toPlayer.normalized) > 0f)
|
||||
{
|
||||
deltaPosition.x = 0f;
|
||||
deltaPosition.z = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
navMeshAgent.Move(deltaPosition);
|
||||
|
||||
if (animator.deltaRotation != Quaternion.identity)
|
||||
transform.rotation *= animator.deltaRotation;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
currentHealth.OnValueChanged -= OnHealthChangedInternal;
|
||||
|
||||
@@ -31,30 +31,16 @@ namespace Colosseum.Player
|
||||
private bool isJumping;
|
||||
private bool wasGrounded;
|
||||
|
||||
// 적 충돌 방향 (이동 차단용)
|
||||
// 적 충돌 차단용
|
||||
private Vector3 blockedDirection;
|
||||
private int enemyLayerMask;
|
||||
private readonly Collider[] overlapBuffer = new Collider[8];
|
||||
|
||||
/// <summary>
|
||||
/// 현재 이동 속도 (애니메이션용)
|
||||
/// </summary>
|
||||
public float CurrentMoveSpeed => moveInput.magnitude * moveSpeed;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 현재 지면 접촉 상태
|
||||
/// </summary>
|
||||
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 점프 중 상태
|
||||
/// </summary>
|
||||
public bool IsJumping => isJumping;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// 로컬 플레이어가 아니면 입력 비활성화
|
||||
if (!IsOwner)
|
||||
{
|
||||
enabled = false;
|
||||
@@ -62,51 +48,33 @@ namespace Colosseum.Player
|
||||
}
|
||||
|
||||
characterController = GetComponent<CharacterController>();
|
||||
// 보스 콜라이더가 겹칠 때 Unity 내부 자동 밀어냄 비활성화.
|
||||
// 적과의 분리는 EnemyBase.ResolvePlayerOverlap에서 보스 측이 담당.
|
||||
characterController.enableOverlapRecovery = false;
|
||||
|
||||
// SkillController 참조
|
||||
if (skillController == null)
|
||||
{
|
||||
skillController = GetComponent<SkillController>();
|
||||
}
|
||||
|
||||
// Animator 참조
|
||||
if (animator == null)
|
||||
{
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
}
|
||||
|
||||
// 스폰 포인트에서 위치 설정
|
||||
SetSpawnPosition();
|
||||
|
||||
// Input Actions 초기화
|
||||
InitializeInputActions();
|
||||
|
||||
// 카메라 설정
|
||||
SetupCamera();
|
||||
|
||||
// 적 레이어 마스크 초기화
|
||||
enemyLayerMask = LayerMask.GetMask("Enemy");
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력 액션 초기화
|
||||
/// </summary>
|
||||
private void InitializeInputActions()
|
||||
{
|
||||
inputActions = new InputSystem_Actions();
|
||||
inputActions.Player.Enable();
|
||||
|
||||
// Move 액션 콜백 등록
|
||||
inputActions.Player.Move.performed += OnMovePerformed;
|
||||
inputActions.Player.Move.canceled += OnMoveCanceled;
|
||||
|
||||
// Jump 액션 콜백 등록
|
||||
inputActions.Player.Jump.performed += OnJumpPerformed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력 액션 해제
|
||||
/// </summary>
|
||||
private void CleanupInputActions()
|
||||
{
|
||||
if (inputActions != null)
|
||||
@@ -120,16 +88,12 @@ namespace Colosseum.Player
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// 컴포넌트 비활성화 시 입력 해제
|
||||
CleanupInputActions();
|
||||
|
||||
// 입력 초기화
|
||||
moveInput = Vector2.zero;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 컴포넌트 재활성화 시 입력 다시 등록
|
||||
if (IsOwner && inputActions != null)
|
||||
{
|
||||
inputActions.Player.Enable();
|
||||
@@ -139,16 +103,11 @@ namespace Colosseum.Player
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스폰 위치 설정
|
||||
/// </summary>
|
||||
private void SetSpawnPosition()
|
||||
{
|
||||
Transform spawnPoint = PlayerSpawnPoint.GetRandomSpawnPoint();
|
||||
|
||||
if (spawnPoint != null)
|
||||
{
|
||||
// CharacterController 비활성화 후 위치 설정 (충돌 문제 방지)
|
||||
characterController.enabled = false;
|
||||
transform.position = spawnPoint.position;
|
||||
transform.rotation = spawnPoint.rotation;
|
||||
@@ -156,68 +115,78 @@ namespace Colosseum.Player
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 네트워크 정리
|
||||
/// </summary>
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
CleanupInputActions();
|
||||
}
|
||||
|
||||
private void OnMovePerformed(InputAction.CallbackContext context)
|
||||
{
|
||||
moveInput = context.ReadValue<Vector2>();
|
||||
}
|
||||
|
||||
private void OnMoveCanceled(InputAction.CallbackContext context)
|
||||
{
|
||||
moveInput = Vector2.zero;
|
||||
}
|
||||
private void OnMovePerformed(InputAction.CallbackContext context) => moveInput = context.ReadValue<Vector2>();
|
||||
private void OnMoveCanceled(InputAction.CallbackContext context) => moveInput = Vector2.zero;
|
||||
|
||||
private void OnJumpPerformed(InputAction.CallbackContext context)
|
||||
{
|
||||
if (!isJumping && characterController.isGrounded)
|
||||
{
|
||||
Jump();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupCamera()
|
||||
{
|
||||
var cameraController = GetComponent<PlayerCamera>();
|
||||
if (cameraController == null)
|
||||
{
|
||||
cameraController = gameObject.AddComponent<PlayerCamera>();
|
||||
}
|
||||
cameraController.Initialize(transform, inputActions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 카메라 재설정 (씬 로드 후 호출)
|
||||
/// </summary>
|
||||
public void RefreshCamera()
|
||||
{
|
||||
SetupCamera();
|
||||
}
|
||||
public void RefreshCamera() => SetupCamera();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
ApplyGravity();
|
||||
UpdateBlockedDirection();
|
||||
Move();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매 프레임 주변 적을 능동적으로 감지하여 blockedDirection을 설정합니다.
|
||||
/// 콜백 기반이 아니므로 보스가 플레이어 쪽으로 밀고 올 때도 즉시 감지합니다.
|
||||
/// </summary>
|
||||
private void UpdateBlockedDirection()
|
||||
{
|
||||
blockedDirection = Vector3.zero;
|
||||
|
||||
Vector3 center = transform.position + characterController.center;
|
||||
float radius = characterController.radius + 0.15f;
|
||||
float halfHeight = Mathf.Max(0f, characterController.height * 0.5f - characterController.radius);
|
||||
|
||||
// 레이어 무관하게 NavMeshAgent 유무로 적 식별
|
||||
int count = Physics.OverlapCapsuleNonAlloc(
|
||||
center + Vector3.up * halfHeight,
|
||||
center - Vector3.up * halfHeight,
|
||||
radius, overlapBuffer);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (overlapBuffer[i].gameObject == gameObject) continue;
|
||||
if (!overlapBuffer[i].TryGetComponent<UnityEngine.AI.NavMeshAgent>(out _)) continue;
|
||||
|
||||
Vector3 toEnemy = overlapBuffer[i].transform.position - transform.position;
|
||||
toEnemy.y = 0f;
|
||||
if (toEnemy.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
blockedDirection = toEnemy.normalized;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
if (wasGrounded && velocity.y < 0)
|
||||
{
|
||||
velocity.y = -2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
velocity.y += gravity * Time.deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
private void Move()
|
||||
@@ -227,51 +196,30 @@ namespace Colosseum.Player
|
||||
// 스킬 애니메이션 재생 중에는 이동 불가 (루트 모션은 OnAnimatorMove에서 처리)
|
||||
if (skillController != null && skillController.IsPlayingAnimation)
|
||||
{
|
||||
// 루트 모션을 사용하지 않는 경우 중력만 적용
|
||||
if (!skillController.UsesRootMotion)
|
||||
{
|
||||
characterController.Move(velocity * Time.deltaTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 이동 방향 계산 (카메라 기준)
|
||||
Vector3 moveDirection = new Vector3(moveInput.x, 0f, moveInput.y);
|
||||
moveDirection = TransformDirectionByCamera(moveDirection);
|
||||
moveDirection.Normalize();
|
||||
|
||||
// 충돌 방향으로의 이동 차단 (미끄러짐 방지)
|
||||
if (blockedDirection != Vector3.zero)
|
||||
{
|
||||
float blockedAmount = Vector3.Dot(moveDirection, blockedDirection);
|
||||
if (blockedAmount > 0f)
|
||||
{
|
||||
moveDirection -= blockedDirection * blockedAmount;
|
||||
moveDirection.Normalize();
|
||||
}
|
||||
}
|
||||
// 적 방향으로 이동 시도 중이면 수평 이동 전체 취소
|
||||
if (blockedDirection != Vector3.zero && Vector3.Dot(moveDirection, blockedDirection) > 0f)
|
||||
moveDirection = Vector3.zero;
|
||||
|
||||
// 이동 적용
|
||||
Vector3 moveVector = moveDirection * moveSpeed * Time.deltaTime;
|
||||
characterController.Move(moveVector + velocity * Time.deltaTime);
|
||||
characterController.Move((moveDirection * moveSpeed + velocity) * Time.deltaTime);
|
||||
|
||||
// 충돌 방향 리셋
|
||||
blockedDirection = Vector3.zero;
|
||||
|
||||
// 회전 (이동 중일 때만)
|
||||
if (moveDirection != Vector3.zero)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
|
||||
}
|
||||
|
||||
// 착지 체크 (Move 후에 isGrounded가 업데이트됨)
|
||||
if (!wasGrounded && characterController.isGrounded && isJumping)
|
||||
{
|
||||
OnJumpEnd();
|
||||
}
|
||||
|
||||
// 다음 프레임을 위해 현재 상태 저장
|
||||
wasGrounded = characterController.isGrounded;
|
||||
}
|
||||
|
||||
@@ -280,41 +228,27 @@ namespace Colosseum.Player
|
||||
isJumping = true;
|
||||
velocity.y = jumpForce;
|
||||
|
||||
// 애니메이션 컨트롤러에 점프 알림
|
||||
var animController = GetComponent<PlayerAnimationController>();
|
||||
if (animController != null)
|
||||
{
|
||||
animController.PlayJump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 점프 중 상태가 끝나면 IsJumping = false;
|
||||
/// </summary>
|
||||
public void OnJumpEnd()
|
||||
{
|
||||
isJumping = false;
|
||||
}
|
||||
public void OnJumpEnd() => isJumping = false;
|
||||
|
||||
private Vector3 TransformDirectionByCamera(Vector3 direction)
|
||||
{
|
||||
if (Camera.main == null) return direction;
|
||||
|
||||
Transform cameraTransform = Camera.main.transform;
|
||||
Vector3 cameraForward = cameraTransform.forward;
|
||||
Vector3 cameraRight = cameraTransform.right;
|
||||
Transform cam = Camera.main.transform;
|
||||
Vector3 forward = new Vector3(cam.forward.x, 0f, cam.forward.z).normalized;
|
||||
Vector3 right = new Vector3(cam.right.x, 0f, cam.right.z).normalized;
|
||||
|
||||
// Y축 제거
|
||||
cameraForward.y = 0f;
|
||||
cameraRight.y = 0f;
|
||||
cameraForward.Normalize();
|
||||
cameraRight.Normalize();
|
||||
|
||||
return cameraRight * direction.x + cameraForward * direction.z;
|
||||
return right * direction.x + forward * direction.z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 루트 모션 처리. 스킬 애니메이션 중에 애니메이션의 이동/회전 데이터를 적용합니다.
|
||||
/// 루트 모션 처리. 스킬 애니메이션 중 애니메이션의 이동/회전 데이터를 적용합니다.
|
||||
/// 적 방향으로의 이동은 취소합니다.
|
||||
/// </summary>
|
||||
private void OnAnimatorMove()
|
||||
{
|
||||
@@ -323,10 +257,19 @@ namespace Colosseum.Player
|
||||
if (skillController == null || !skillController.IsPlayingAnimation) return;
|
||||
if (!skillController.UsesRootMotion) return;
|
||||
|
||||
// 루트 모션 이동 적용
|
||||
Vector3 deltaPosition = animator.deltaPosition;
|
||||
|
||||
// Y축 무시 설정 시 중력 유지
|
||||
// 적 방향으로 루트 모션이 향하면 수평 이동 취소
|
||||
if (blockedDirection != Vector3.zero)
|
||||
{
|
||||
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
|
||||
if (Vector3.Dot(deltaXZ, blockedDirection) > 0f)
|
||||
{
|
||||
deltaPosition.x = 0f;
|
||||
deltaPosition.z = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
if (skillController.IgnoreRootMotionY)
|
||||
{
|
||||
deltaPosition.y = 0f;
|
||||
@@ -337,57 +280,13 @@ namespace Colosseum.Player
|
||||
characterController.Move(deltaPosition);
|
||||
}
|
||||
|
||||
// 루트 모션 회전 적용
|
||||
if (animator.deltaRotation != Quaternion.identity)
|
||||
{
|
||||
transform.rotation *= animator.deltaRotation;
|
||||
}
|
||||
|
||||
// 착지 체크
|
||||
if (!wasGrounded && characterController.isGrounded && isJumping)
|
||||
{
|
||||
OnJumpEnd();
|
||||
}
|
||||
|
||||
wasGrounded = characterController.isGrounded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CharacterController 충돌 처리. 적과 충돌 시 해당 방향 이동을 차단합니다.
|
||||
/// 충돌 normal을 8방향으로 양자화하여 각진 충돌 느낌을 줍니다.
|
||||
/// </summary>
|
||||
private void OnControllerColliderHit(ControllerColliderHit hit)
|
||||
{
|
||||
// 적과의 충돌인지 확인
|
||||
if ((enemyLayerMask & (1 << hit.gameObject.layer)) != 0)
|
||||
{
|
||||
// 충돌 방향 저장 (이동 차단용)
|
||||
blockedDirection = hit.normal;
|
||||
blockedDirection.y = 0f;
|
||||
blockedDirection.Normalize();
|
||||
|
||||
// 8방향으로 양자화 (45도 간격)
|
||||
blockedDirection = QuantizeToOctagon(blockedDirection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 방향을 8각형(45도 간격) 방향으로 양자화합니다.
|
||||
/// </summary>
|
||||
private Vector3 QuantizeToOctagon(Vector3 direction)
|
||||
{
|
||||
if (direction == Vector3.zero)
|
||||
return direction;
|
||||
|
||||
// 각도 계산
|
||||
float angle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
|
||||
|
||||
// 45도 단위로 반올림
|
||||
float snappedAngle = Mathf.Round(angle / 45f) * 45f;
|
||||
|
||||
// 다시 벡터로 변환
|
||||
float radians = snappedAngle * Mathf.Deg2Rad;
|
||||
return new Vector3(Mathf.Sin(radians), 0f, Mathf.Cos(radians));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user