using UnityEngine; using UnityEngine.InputSystem; using Unity.Netcode; using Unity.Netcode.Components; using Unity.Cinemachine; [RequireComponent(typeof(NetworkObject))] public class PlayerNetworkController : NetworkBehaviour { // ... 기존 변수들 유지 ... [Header("Movement Settings")] public float moveSpeed = 5f; public float rotationSpeed = 10f; public float jumpHeight = 1.5f; public float gravity = -19.62f; [Header("Interaction Settings")] [SerializeField] private float interactRange = 3f; [SerializeField] private LayerMask interactableLayer; [SerializeField] private LayerMask constructionLayer; [SerializeField] private float buildSpeedMultiplier = 2f; [Header("Mining Settings")] [SerializeField] private float attackRange = 3.5f; [SerializeField] private float aimRadius = 0.5f; [SerializeField] private int miningDamage = 50; [SerializeField] private LayerMask mineableLayer; [SerializeField] private LayerMask ignoreDuringAim; // 반드시 'Player' 레이어를 포함하세요! [Header("Visual Feedback")] [SerializeField] private float crosshairScreenRadius = 200f; [SerializeField] private UnityEngine.UI.Image crosshairUI; [SerializeField] private Sprite idleCrosshair; [SerializeField] private Sprite targetCrosshair; private RectTransform _crosshairRect; private MineableBlock _currentTargetBlock; // 현재 강조 중인 블록 저장 private CharacterController _controller; private PlayerInputActions _inputActions; private Animator _animator; private TunnelTraveler _traveler; private Vector2 _moveInput; private Vector3 _velocity; private Vector3 _currentMoveDir; private bool _isGrounded; private bool _isHoldingInteract = false; // 디버그 변수 private Vector3 _debugOrigin; private Vector3 _debugDir; private bool _debugHit; private float _debugDist; // NGO 초기화 public override void OnNetworkSpawn() { if (!IsOwner) return; // 1. 씬에 있는 가상 카메라를 찾습니다. // Unity 6에서는 CinemachineVirtualCamera 대신 CinemachineCamera를 주로 사용합니다. var vcam = GameObject.FindAnyObjectByType(); if (vcam != null) { // 2. 카메라의 Follow와 LookAt 대상을 '나'로 설정합니다. vcam.Follow = transform; vcam.LookAt = transform; Debug.Log("[Camera] 로컬 플레이어에게 카메라가 연결되었습니다."); } // 씬의 Canvas 안에 있는 "Crosshair"라는 이름의 오브젝트를 찾습니다. GameObject crosshairObj = GameObject.Find("Crosshair"); if (crosshairObj != null) { _crosshairRect = crosshairObj.GetComponent(); crosshairUI = crosshairObj.GetComponent(); // 초기 스프라이트 설정 crosshairUI.sprite = idleCrosshair; } _inputActions = new PlayerInputActions(); _inputActions.Player.Jump.performed += ctx => OnJump(); _inputActions.Player.Attack.performed += ctx => OnAttackInput(); _inputActions.Player.Interact.performed += ctx => OnInteractTap(); // 탭 상호작용 _inputActions.Player.Interact.started += ctx => _isHoldingInteract = true; _inputActions.Player.Interact.canceled += ctx => _isHoldingInteract = false; _inputActions.Enable(); } void Awake() { _controller = GetComponent(); _animator = GetComponent(); _traveler = GetComponent(); } void Update() { if (!IsOwner) return; if (_traveler != null && _traveler.IsTraveling) return; HandleGravity(); HandleMovement(); if (_isHoldingInteract) PerformConstructionSupport(); UpdateCrosshairPosition(); // 이 안에서 움직여야 합니다. UpdateTargetFeedback(); } // --- 이동 관련 로직 (기존 유지) --- private void HandleMovement() { _isGrounded = _controller.isGrounded; _animator.SetBool("isGrounded", _isGrounded); bool isAttacking = _animator.GetCurrentAnimatorStateInfo(0).IsTag("Attack"); _moveInput = _inputActions.Player.Move.ReadValue(); Vector3 move = new Vector3(_moveInput.x, 0, _moveInput.y).normalized; if (isAttacking) move = _isGrounded ? Vector3.zero : _currentMoveDir; else if (move.magnitude > 0.1f) _currentMoveDir = move; if (move.magnitude >= 0.1f) { if (!isAttacking) { Quaternion targetRotation = Quaternion.LookRotation(move); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); } _controller.Move(move * moveSpeed * Time.deltaTime); } _animator.SetFloat("MoveSpeed", isAttacking && _isGrounded ? 0 : move.magnitude); } private void HandleGravity() { if (_isGrounded && _velocity.y < 0) _velocity.y = -2f; _velocity.y += gravity * Time.deltaTime; _controller.Move(_velocity * Time.deltaTime); } private void OnJump() { if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } private void OnAttackInput() { if (!IsOwner) return; // 1. 마우스가 가리키는 월드상의 위치를 먼저 찾습니다. Ray mouseRay = Camera.main.ScreenPointToRay(Mouse.current.position.ReadValue()); Vector3 worldAimPoint; // 아주 멀리까지 레이를 쏴서 조준 방향을 결정합니다. if (Physics.Raycast(mouseRay, out RaycastHit mouseHit, 1000f, ~ignoreDuringAim)) worldAimPoint = mouseHit.point; else worldAimPoint = mouseRay.GetPoint(100f); // 2. 캐릭터 가슴 높이에서 조준점을 향하는 방향 계산 Vector3 origin = transform.position + Vector3.up * 1.2f; Vector3 direction = (worldAimPoint - origin).normalized; // 3. 캐릭터에서 해당 방향으로 SphereCast를 쏴서 가장 가까운 블록 하나를 찾습니다. // SphereCast는 가장 먼저 닿는 오브젝트 하나만 hit에 담습니다. if (Physics.SphereCast(origin, aimRadius, direction, out RaycastHit blockHit, attackRange, mineableLayer)) { if (blockHit.collider.TryGetComponent(out var netObj)) { // 서버에 대미지 요청 ApplyMiningDamageServerRpc(netObj.NetworkObjectId); } } _animator.SetTrigger("Attack"); } [Rpc(SendTo.Server)] private void ApplyMiningDamageServerRpc(ulong targetId) { if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out var target)) { if (target.TryGetComponent(out var block)) { // 서버에서 최종 거리 검증 후 대미지 적용 if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f) { block.TakeDamageRpc(miningDamage); block.PlayHitEffectClientRpc(); } } } } [ClientRpc] private void OnAttackClientRpc() => _animator.SetTrigger("Attack"); private void OnInteractTap() { if (!IsOwner) return; _animator.SetTrigger("Interact"); // 1. 캐릭터 주변 반경 내의 모든 콜라이더 감지 Collider[] colliders = Physics.OverlapSphere(transform.position, interactRange, interactableLayer); IInteractable closestTarget = null; float minDistance = float.MaxValue; foreach (var col in colliders) { // 2. 방향 조건 없이 거리만 체크하여 가장 가까운 것 선택 float dist = Vector3.Distance(transform.position, col.transform.position); if (dist < minDistance) { IInteractable interactable = col.GetComponentInParent(); if (interactable != null) { minDistance = dist; closestTarget = interactable; } } } // 3. 가장 가까운 대상이 있다면 상호작용 실행 if (closestTarget != null) { closestTarget.Interact(gameObject); } } // 건설 지원 로직 (범위 내 지속 작업이므로 OverlapSphere 유지 또는 Raycast로 변경 가능) private void PerformConstructionSupport() { Collider[] targets = Physics.OverlapSphere(transform.position, interactRange, constructionLayer); foreach (var col in targets) { ConstructionSite site = col.GetComponentInParent(); if (site != null) { site.AdvanceConstruction(Time.deltaTime * buildSpeedMultiplier); } } } public override void OnNetworkDespawn() { if (IsOwner && _inputActions != null) _inputActions.Disable(); } private void UpdateCrosshairPosition() { // 1. 변수 할당 확인 (할당이 안 되어 있으면 여기서 찾음) if (_crosshairRect == null) { GameObject go = GameObject.Find("Crosshair"); if (go != null) _crosshairRect = go.GetComponent(); else return; // 여전히 없으면 중단 } // 2. 마우스 입력 읽기 (New Input System) Vector2 mousePos = Mouse.current.position.ReadValue(); Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f); // 3. 중앙으로부터의 거리 계산 Vector2 offset = mousePos - screenCenter; // 4. [중요] 반지름 제한 확인 (crosshairScreenRadius가 0이면 이동하지 않음) if (crosshairScreenRadius > 0) { // 내적과 크기 계산을 통해 원형 제한 적용 if (offset.magnitude > crosshairScreenRadius) { offset = offset.normalized * crosshairScreenRadius; } } // 5. UI 좌표 적용 (Screen Space - Overlay 기준) _crosshairRect.position = screenCenter + offset; } private void UpdateTargetFeedback() { if (!IsOwner || _crosshairRect == null) return; // 1. [조준점 확보] 카메라 레이로 마우스가 가리키는 '실제 지점'을 찾습니다. Ray cameraRay = Camera.main.ScreenPointToRay(_crosshairRect.position); RaycastHit cameraHit; // 지형이나 블록을 모두 검사하여 조준점을 잡습니다. bool hitSomething = Physics.Raycast(cameraRay, out cameraHit, 150f, ~ignoreDuringAim); Vector3 worldAimPoint = hitSomething ? cameraHit.point : cameraRay.GetPoint(100f); // 2. [거리 및 방향 계산] 캐릭터 가슴에서 그 지점까지의 벡터를 구합니다. Vector3 origin = transform.position + Vector3.up * 1.2f; Vector3 toTarget = worldAimPoint - origin; float distToTarget = toTarget.magnitude; Vector3 direction = toTarget.normalized; // 3. [대상 우선 판정] // 만약 카메라 레이가 '채광 가능한 블록'을 직접 때렸고, 그게 사거리 이내라면? -> 바로 타겟팅! bool isDirectHit = hitSomething && ((1 << cameraHit.collider.gameObject.layer) & mineableLayer) != 0; RaycastHit finalHit; bool hasValidTarget = false; if (isDirectHit && distToTarget <= attackRange) { // 마우스가 직접 블록을 가리키고 사거리 내에 있는 경우 finalHit = cameraHit; hasValidTarget = true; } else { // 마우스가 허공을 보거나 너무 먼 곳을 볼 때만 '범위 탐색(SphereCast)'을 수행합니다. float searchDist = Mathf.Min(distToTarget, attackRange); Vector3 rayStart = origin + direction * 0.4f; hasValidTarget = Physics.SphereCast(rayStart, aimRadius, direction, out finalHit, searchDist - 0.4f, mineableLayer); } // 4. 디버그 및 시각화 업데이트 _debugOrigin = origin; _debugDir = direction; _debugHit = hasValidTarget; _debugDist = hasValidTarget ? Vector3.Distance(origin, finalHit.point) : Mathf.Min(distToTarget, attackRange); UpdateBlockVisuals(hasValidTarget, finalHit); } private void UpdateBlockVisuals(bool hasTarget, RaycastHit hit) { MineableBlock newTarget = null; if (hasTarget) { // 부모나 자신에게서 MineableBlock 컴포넌트를 찾습니다. newTarget = hit.collider.GetComponentInParent(); } // 대상이 바뀌었을 때만 실행 (최적화) if (_currentTargetBlock != newTarget) { // 1. 이전 타겟 하이라이트 해제 if (_currentTargetBlock != null) { _currentTargetBlock.SetHighlight(false); } // 2. 새로운 타겟 하이라이트 적용 if (newTarget != null) { newTarget.SetHighlight(true); } _currentTargetBlock = newTarget; } // 3. 크로스헤어 상태 업데이트 if (crosshairUI != null) { crosshairUI.sprite = hasTarget ? targetCrosshair : idleCrosshair; crosshairUI.color = hasTarget ? Color.green : Color.white; } } private void OnDrawGizmos() { if (!Application.isPlaying || !IsOwner) return; // 실제 채굴 탐색 궤적을 씬 뷰에 표시 Gizmos.color = _debugHit ? Color.red : Color.green; // 광선 표시 Gizmos.DrawLine(_debugOrigin, _debugOrigin + _debugDir * _debugDist); // 탐색 영역(구체) 표시 Gizmos.DrawWireSphere(_debugOrigin + _debugDir * _debugDist, aimRadius); } }