블록 조준 시스템 제작

및 기본 에디터 자동 호스트화
This commit is contained in:
2026-01-17 14:32:05 +09:00
parent b6822691b6
commit 11b9112739
36 changed files with 2644 additions and 36 deletions

View File

@@ -0,0 +1,26 @@
using UnityEngine;
using Unity.Netcode;
public class AutoHost : MonoBehaviour
{
// 에디터에서만 작동하도록 설정
void Start()
{
#if UNITY_EDITOR
// 1. NetworkManager가 씬에 존재하는지 확인
if (NetworkManager.Singleton != null)
{
// 2. 이미 서버나 클라이언트가 실행 중이 아닐 때만 실행
if (!NetworkManager.Singleton.IsServer && !NetworkManager.Singleton.IsClient)
{
NetworkManager.Singleton.StartHost();
Debug.Log("<color=yellow><b>[AutoHost]</b> 에디터 전용 호스트 자동 시작됨</color>");
}
}
else
{
Debug.LogError("[AutoHost] NetworkManager를 찾을 수 없습니다!");
}
#endif
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 158afddc7f1d50a42831c73d5e8dc87b

View File

@@ -1,5 +1,6 @@
using UnityEngine;
using Unity.Netcode;
using UnityEngine;
using System.Collections;
public class MineableBlock : NetworkBehaviour
{
@@ -8,8 +9,32 @@ public class MineableBlock : NetworkBehaviour
// [동기화] 모든 플레이어가 동일한 블록 체력을 보게 함
private NetworkVariable<int> _currentHp = new NetworkVariable<int>();
[Header("Visuals")]
[SerializeField] private GameObject breakEffectPrefab; // 파괴 시 파티클
private Outline _outline;
private Vector3 _originalPos;
[Header("Shake Settings")]
[SerializeField] private float shakeDuration = 0.15f; // 흔들리는 시간
[SerializeField] private float shakeMagnitude = 0.1f; // 흔들리는 강도
private Coroutine _shakeCoroutine;
void Awake()
{
// 해당 오브젝트 혹은 자식에게서 Outline 컴포넌트를 찾습니다.
_outline = GetComponentInChildren<Outline>();
_originalPos = transform.localPosition; // 로컬 위치 저장
if (_outline != null)
{
// 게임 시작 시 하이라이트는 꺼둡니다.
_outline.enabled = false;
}
else
{
Debug.LogWarning($"{gameObject.name}: QuickOutline 에셋의 Outline 컴포넌트를 찾을 수 없습니다.");
}
}
public override void OnNetworkSpawn()
{
@@ -35,19 +60,45 @@ public class MineableBlock : NetworkBehaviour
private void DestroyBlock()
{
// 1. 모든 클라이언트에게 파괴 이펙트 재생 요청
PlayBreakEffectRpc();
// 2. 서버에서 네트워크 오브젝트 제거 (모든 클라이언트에서 사라짐)
GetComponent<NetworkObject>().Despawn();
}
[Rpc(SendTo.Everyone)]
private void PlayBreakEffectRpc()
// 하이라이트 상태를 설정하는 공개 메서드
public void SetHighlight(bool isOn)
{
if (breakEffectPrefab != null)
if (_outline == null) return;
// 외곽선 컴포넌트 활성화/비활성화
_outline.enabled = isOn;
}
// 서버에서 호출하여 모든 클라이언트에게 흔들림 지시
[ClientRpc]
public void PlayHitEffectClientRpc()
{
if (_shakeCoroutine != null) StopCoroutine(_shakeCoroutine);
_shakeCoroutine = StartCoroutine(ShakeRoutine());
}
private IEnumerator ShakeRoutine()
{
float elapsed = 0.0f;
Debug.Log("흔들림 코루틴 시작"); // 시작 확인
while (elapsed < shakeDuration)
{
Instantiate(breakEffectPrefab, transform.position, Quaternion.identity);
Vector3 randomOffset = Random.insideUnitSphere * shakeMagnitude;
transform.localPosition = _originalPos + randomOffset;
// 좌표가 실제로 바뀌고 있는지 로그 출력
// Debug.Log($"현재 좌표: {transform.localPosition}");
elapsed += Time.deltaTime;
yield return null;
}
transform.localPosition = _originalPos;
Debug.Log("흔들림 코루틴 종료 및 위치 복구");
}
}

View File

@@ -21,9 +21,19 @@ public class PlayerNetworkController : NetworkBehaviour
[SerializeField] private float buildSpeedMultiplier = 2f;
[Header("Mining Settings")]
[SerializeField] private float attackRange = 1.5f;
[SerializeField] private int miningDamage = 25;
[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;
@@ -36,6 +46,12 @@ public class PlayerNetworkController : NetworkBehaviour
private bool _isGrounded;
private bool _isHoldingInteract = false;
// 디버그 변수
private Vector3 _debugOrigin;
private Vector3 _debugDir;
private bool _debugHit;
private float _debugDist;
// NGO 초기화
public override void OnNetworkSpawn()
{
@@ -53,9 +69,19 @@ public class PlayerNetworkController : NetworkBehaviour
Debug.Log("<color=green>[Camera] 로컬 플레이어에게 카메라가 연결되었습니다.</color>");
}
// 씬의 Canvas 안에 있는 "Crosshair"라는 이름의 오브젝트를 찾습니다.
GameObject crosshairObj = GameObject.Find("Crosshair");
if (crosshairObj != null)
{
_crosshairRect = crosshairObj.GetComponent<RectTransform>();
crosshairUI = crosshairObj.GetComponent<UnityEngine.UI.Image>();
// 초기 스프라이트 설정
crosshairUI.sprite = idleCrosshair;
}
_inputActions = new PlayerInputActions();
_inputActions.Player.Jump.performed += ctx => OnJump();
_inputActions.Player.Attack.performed += ctx => OnAttackServerRpc();
_inputActions.Player.Attack.performed += ctx => OnAttackInput();
_inputActions.Player.Interact.performed += ctx => OnInteractTap(); // 탭 상호작용
_inputActions.Player.Interact.started += ctx => _isHoldingInteract = true;
@@ -80,6 +106,9 @@ public class PlayerNetworkController : NetworkBehaviour
HandleMovement();
if (_isHoldingInteract) PerformConstructionSupport();
UpdateCrosshairPosition(); // 이 안에서 움직여야 합니다.
UpdateTargetFeedback();
}
// --- 이동 관련 로직 (기존 유지) ---
@@ -119,16 +148,52 @@ public class PlayerNetworkController : NetworkBehaviour
if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
// --- 채광 로직 (기존 유지) ---
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
private void OnAttackServerRpc()
private void OnAttackInput()
{
OnAttackClientRpc();
Collider[] hitBlocks = Physics.OverlapSphere(transform.position + transform.forward, attackRange, mineableLayer);
foreach (var col in hitBlocks)
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))
{
MineableBlock block = col.GetComponentInParent<MineableBlock>();
if (block != null) block.TakeDamageRpc(miningDamage);
if (blockHit.collider.TryGetComponent<NetworkObject>(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<MineableBlock>(out var block))
{
// 서버에서 최종 거리 검증 후 대미지 적용
if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f)
{
block.TakeDamageRpc(miningDamage);
block.PlayHitEffectClientRpc();
}
}
}
}
@@ -186,4 +251,132 @@ public class PlayerNetworkController : NetworkBehaviour
{
if (IsOwner && _inputActions != null) _inputActions.Disable();
}
private void UpdateCrosshairPosition()
{
// 1. 변수 할당 확인 (할당이 안 되어 있으면 여기서 찾음)
if (_crosshairRect == null)
{
GameObject go = GameObject.Find("Crosshair");
if (go != null) _crosshairRect = go.GetComponent<RectTransform>();
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<MineableBlock>();
}
// 대상이 바뀌었을 때만 실행 (최적화)
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);
}
}