2026-06-18 많은 플레이어 수정사항

This commit is contained in:
2026-06-18 15:07:21 +09:00
parent dff151c5f0
commit 67c2b2c179
30 changed files with 543 additions and 22 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76ca75090be3f364bbef8965c09e22e5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,132 @@
using System;
using UnityEngine;
// 플레이어에 붙는다. 매 프레임 반경(_detectRadius) 안의 InteractionObject를 OverlapSphere로 찾아
// 가장 가까운 대상을 Current로 노출한다. Interact 키를 누르면 그 대상과 상호작용한다.
// 지속형 상호작용(앉기 등) 중에는 대상을 잠가, 다음 키 입력이 종료로 가게 한다.
//
// 트리거 이벤트가 아니라 물리 쿼리(OverlapSphere)라 Rigidbody/트리거/충돌 매트릭스 설정이 필요 없다.
// 의자 등 대상에 Collider만 있으면 감지된다.
public class InteractionDetector : MonoBehaviour, ISceneInitializable
{
[SerializeField] private PlayerController _player;
[SerializeField] private float _detectRadius = 1.5f; // 감지 반경(m)
[SerializeField] private LayerMask _detectLayers = ~0; // 감지할 레이어 (기본: 전체)
[SerializeField] private bool _debugLog = true; // 콘솔에 감지/입력 로그 (문제 진단용)
private InteractionObject _current; // 가장 가까운 상호작용 대상 (없으면 null)
private InteractionObject _active; // 진행 중인 지속형 상호작용 (앉아있는 의자 등). 있으면 대상 잠금
private readonly Collider[] _hits = new Collider[16]; // OverlapSphereNonAlloc 결과 버퍼
// UI 프롬프트용: 상호작용 가능한 대상이 바뀔 때 호출 (null이면 "대상 없음")
public event Action<InteractionObject> OnInteractableChanged;
public InteractionObject Current => _current; // 현재 상호작용 가능한 대상
public bool HasActiveInteraction => _active != null;
private void Awake()
{
if (_player == null) _player = GetComponentInParent<PlayerController>();
}
private void Start() => Subscribe(); // SceneLoadManager가 없는 씬에서도 동작하도록 폴백 구독
// 씬 로드 후 SceneLoadManager가 호출 (있을 때)
public void OnSceneLoaded() => Subscribe();
// 중복 없이 InputManager 구독 (Start/OnSceneLoaded 양쪽에서 호출돼도 안전)
private void Subscribe()
{
if (InputManager.Instance == null)
{
Debug.LogWarning("[Interaction] InputManager.Instance가 없어 Interact 입력을 구독하지 못했습니다.", this);
return;
}
InputManager.Instance.OnInteract_Event -= HandleInteract;
InputManager.Instance.OnInteract_Event += HandleInteract;
}
private void OnDestroy()
{
if (InputManager.Instance != null)
InputManager.Instance.OnInteract_Event -= HandleInteract;
}
private void Update()
{
// 진행 중인 상호작용이 있으면 대상 고정(잠금) — 가장 가까운 대상 갱신 안 함
if (_active != null) return;
UpdateCurrent();
}
// 반경 안에서 가장 가까운 InteractionObject를 골라 Current 갱신 (바뀌면 이벤트 발생)
private void UpdateCurrent()
{
InteractionObject nearest = null;
float best = float.MaxValue;
Vector3 p = transform.position;
// 마스크가 Nothing(0)이면 전체 레이어로 (인스펙터에서 미설정 시 안전장치)
int mask = _detectLayers.value == 0 ? ~0 : _detectLayers.value;
// 트리거 콜라이더도 잡도록 QueryTriggerInteraction.Collide
int count = Physics.OverlapSphereNonAlloc(p, _detectRadius, _hits, mask, QueryTriggerInteraction.Collide);
for (int i = 0; i < count; i++)
{
Collider c = _hits[i];
if (c == null) continue;
// 콜라이더가 자식이어도 부모 쪽까지 탐색
InteractionObject obj = c.GetComponentInParent<InteractionObject>();
if (obj == null) continue;
float d = (obj.transform.position - p).sqrMagnitude;
if (d < best) { best = d; nearest = obj; }
}
if (nearest != _current)
{
_current = nearest;
if (_debugLog) Debug.Log($"[Interaction] 대상 변경: {(nearest ? nearest.name : "none")}", this);
OnInteractableChanged?.Invoke(_current); // UI 프롬프트 갱신용
}
}
// Interact 키 입력 처리
private void HandleInteract()
{
if (_debugLog)
Debug.Log($"[Interaction] 키 입력. active={(_active ? _active.name : "none")}, current={(_current ? _current.name : "none")}");
if (_player == null)
{
Debug.LogWarning("[Interaction] Player 참조가 없습니다. (Awake 자동탐색 실패)", this);
return;
}
// 1) 진행 중인 상호작용이 있으면 그 대상에게 (보통 종료/일어서기)
if (_active != null)
{
_active.Interact(_player);
if (!_active.IsInteracting) _active = null; // 종료됨 → 다음 Update에서 _current 재계산
return;
}
// 2) 아니면 가장 가까운 대상과 새로 상호작용 시작
if (_current == null) return;
_current.Interact(_player);
if (_current.IsInteracting)
{
_active = _current; // 지속형이면 대상 잠금
_current = null;
OnInteractableChanged?.Invoke(null); // 잠금 중엔 프롬프트 숨김
}
}
// 씬 뷰에서 감지 반경 시각화 (선택 시)
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, _detectRadius);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1c172720ccb5a4c41a34476feb7ddaba

View File

@@ -0,0 +1,20 @@
using UnityEngine;
// 상호작용 가능한 오브젝트의 베이스. InteractionDetector가 이 타입을 감지하고 Interact()를 호출한다.
// 감지를 위해 Collider가 반드시 필요 (RequireComponent로 강제).
[RequireComponent(typeof(Collider))]
public abstract class InteractionObject : MonoBehaviour
{
[SerializeField] protected string _promptText = "상호작용"; // UI 프롬프트 문구 (예: "앉으려면 E")
[SerializeField] protected Transform _interactionPos;
// 지속형 상호작용이 진행 중인지 (예: 앉아있는 중).
// true인 동안 디텍터는 대상을 이 오브젝트로 잠가, 다음 키 입력이 종료(해제)로 가게 한다.
public bool IsInteracting { get; protected set; }
public virtual string PromptText => _promptText;
// 상호작용 키를 눌렀을 때 호출. 시작/종료(토글) 여부는 각 구현이 결정한다.
public abstract void Interact(PlayerController player);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f43a516396901747867c5f1dc2b7041

View File

@@ -0,0 +1,27 @@
using UnityEngine;
// 앉기 상호작용. 한 번 누르면 앉고(이동 잠금), 다시 누르면 일어선다.
public class SitObject : InteractionObject
{
// 컴포넌트 추가 시 기본 프롬프트 문구를 앉기용으로 설정
private void Reset()
{
_promptText = "앉으려면 E";
}
public override void Interact(PlayerController player)
{
if (player == null) return;
if (!IsInteracting)
{
player.Sit(_interactionPos); // 좌석 위치로 이동 후 앉기 (_interactionPos 비우면 제자리)
IsInteracting = true; // 앉음 → 디텍터가 대상 잠금
}
else
{
player.StandUp();
IsInteracting = false; // 일어섬 → 잠금 해제
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ea79a7e991b1004da87e7518d7c3860

View File

@@ -25,12 +25,18 @@ public class RhythmManager : MonoBehaviour
[SerializeField] private GameObject StartButtonObj;
[Header("시작 카운트다운 (VR 준비 시간)")]
[SerializeField] private float _countdownTime = 5f; // 시작 버튼 후 노래가 실제로 시작되기까지 대기 시간(초)
[SerializeField] private AudioClip _countdownBeep; // 매초 카운트 효과음 (5,4,3,2,1)
[SerializeField] private AudioClip _countdownGoSfx; // 카운트 끝(GO) 효과음 (선택)
// 모든 타이밍의 기준. 오디오 클럭(dspTime) 기반이라 리드인 동안 음수(-leadTime→0)로 흐른다
public float SongTime => (float)(AudioSettings.dspTime - _dspSongStart);
private List<Note> SongNoteList;
private int _nextNoteIndex; // 다음에 소환할 노트 인덱스
private bool _isPlaying; //곡이 재생 중인지 (종료 감지용)
private bool _isCountingDown; // 시작 카운트다운 진행 중 여부 (중복 클릭 방지용)
private double _dspSongStart; // 오디오가 실제 시작되는 dsp 시각 (= SongTime 0 지점)
private float _clipLength; // 곡 길이 캐시 (종료 감지용)
private Func<float> _songTimeProvider; // 노트에 넘길 시간 제공자 (매 스폰마다 람다 재생성 방지용 캐시)
@@ -44,6 +50,10 @@ public class RhythmManager : MonoBehaviour
public event Action<RhythmScore> OnScoreChanged; // 점수/콤보가 바뀔 때 (실시간 HUD)
public event Action<RhythmScore> OnSongFinished; // 곡이 끝났을 때 (결과창)
public event Action OnCountdownStarted; // 시작 버튼 직후 카운트다운 시작 (CountdownHud 표시)
public event Action<int> OnCountdownTick; // 남은 초 (5,4,3,2,1) — 매초 호출
public event Action OnCountdownFinished; // 카운트 종료(GO!) — 곡 시작 직전
private RhythmCat[] _rhythmCats;
private void Awake()
@@ -151,8 +161,55 @@ public void ChangeSong()
// 오토플레이 아이템 사용: 곡 시작 전(또는 도중)에 호출하면 남은 노트가 전부 자동 Perfect
public void EnableAutoPlay() => _autoPlay = true;
// 곡 재생 + 채보 로드
// 시작 버튼 핸들러: 곧장 시작하지 않고 VR 준비 시간(카운트다운) 후 BeginSong 호출
public void StartSong()
{
if (_isPlaying) return; // 이미 재생 중이면 무시
if (_isCountingDown) return; // 카운트다운 진행 중 중복 클릭 차단
if (StartButtonObj != null) StartButtonObj.SetActive(false); // 버튼 즉시 숨김
_ = CountdownThenBeginAsync(); // fire-and-forget (await 결과 불필요)
}
// 매초 비프음 + 남은 초 이벤트를 보내며 _countdownTime 만큼 대기한 뒤 곡 시작
private async Awaitable CountdownThenBeginAsync()
{
_isCountingDown = true;
try
{
OnCountdownStarted?.Invoke(); // CountdownHud 표시
int remaining = Mathf.Max(1, Mathf.CeilToInt(_countdownTime));
while (remaining > 0)
{
OnCountdownTick?.Invoke(remaining); // 화면에 숫자 표시
if (_countdownBeep != null && SoundManager.Instance != null)
SoundManager.Instance.PlaySFX(_countdownBeep); // 매초 비프음
// destroyCancellationToken: 카운트다운 도중 오브젝트가 파괴되면 await가 취소돼
// 파괴된 객체에서 BeginSong이 호출되는 걸 막는다
await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
remaining--;
}
}
catch (OperationCanceledException)
{
return; // 파괴/취소 시 곡 시작하지 않고 종료
}
finally
{
_isCountingDown = false;
}
OnCountdownFinished?.Invoke(); // GO! 표시
if (_countdownGoSfx != null && SoundManager.Instance != null)
SoundManager.Instance.PlaySFX(_countdownGoSfx);
BeginSong();
}
// 곡 재생 + 채보 로드 (카운트다운 종료 후 실제 시작)
private void BeginSong()
{
SongNoteList = _currentChart.GenerateNotes();
_nextNoteIndex = 0;
@@ -161,7 +218,6 @@ public void StartSong()
Score.Reset();
OnSongStarted?.Invoke(); // ScoreHud 표시 / ResultHud 숨김
OnScoreChanged?.Invoke(Score); // HUD 초기화(0점)
StartButtonObj.SetActive(false);
_clipLength = _audioSource.clip != null ? _audioSource.clip.length : 0f;

View File

@@ -11,7 +11,8 @@ public class InputManager : MonoBehaviour, GameInput.IPlayerActions
// ─── 입력 이벤트들 (PlayerController 등이 구독) ──────────────────────
public event Action OnJump_Event; // 한 번씩 (눌렀을 때)
public event Action OnInteract_Event; // 상호작용 키 (앉기 등) — 눌렀을 때 한 번씩
//키보드로 테스트용
public event Action OnKey_Left_Event;
public event Action OnKey_Right_Event;
@@ -44,6 +45,12 @@ public void OnJump(InputAction.CallbackContext ctx)
OnJump_Event?.Invoke();
}
public void OnInteract(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)
OnInteract_Event?.Invoke();
}
public void OnKey_Left(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)

View File

@@ -1,3 +1,4 @@
using Unity.XR.CoreUtils;
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit.Locomotion.Movement;
@@ -9,12 +10,17 @@ public class PlayerController : MonoBehaviour, ISceneInitializable
private Vector3 _playerVelocity;
private CharacterController _controller;
private ContinuousMoveProvider _moveProvider;
private XROrigin _xrOrigin;
private float gravityValue = Physics.gravity.y;
public bool IsSitting { get; private set; } // 앉아있는 동안 true (이동/점프 잠금)
private float _standingHeight; // 앉기 전 카메라 높이(일어설 때 복원용)
private void Awake()
{
_controller = GetComponentInChildren<CharacterController>(true);
_moveProvider = GetComponentInChildren<ContinuousMoveProvider>(true);
_xrOrigin = GetComponentInChildren<XROrigin>(true);
}
public void OnSceneLoaded()
@@ -24,14 +30,62 @@ public void OnSceneLoaded()
InputManager.Instance.OnJump_Event += this.OnJump;
}
public void SetHeight(float height)
{
_xrOrigin.CameraYOffset = height;
}
public void OnJump()
{
if (_controller == null) return;
if (IsSitting) return; // 앉아있는 동안은 점프 금지
if (!_controller.isGrounded) return;
_playerVelocity.y = Mathf.Sqrt(_jumpHeight * -2f * gravityValue);
}
// 앉기 시작: (좌석 지정 시) 그 위치로 이동 후 카메라 높이를 낮추고 이동을 잠근다.
public void Sit(Transform sitPoint = null)
{
if (IsSitting) return;
IsSitting = true;
if (sitPoint != null) TeleportRig(sitPoint); // 좌석 위치/방향으로 순간이동
_standingHeight = _xrOrigin.CameraYOffset; // 현재 높이 기억
SetHeight(1.2f);
if (_moveProvider != null) _moveProvider.enabled = false; // 이동 잠금
}
// XR 리그(=플레이어 전체)를 target의 위치/방향으로 순간이동.
// CharacterController는 켜진 채로 transform을 직접 바꾸면 되돌려지므로 잠시 끈다.
private void TeleportRig(Transform target)
{
Transform rig = _xrOrigin != null ? _xrOrigin.transform : transform;
bool ccWasEnabled = _controller != null && _controller.enabled;
if (_controller != null) _controller.enabled = false;
// 회전은 수평(yaw)만 적용해 기울어짐 방지
rig.SetPositionAndRotation(target.position, Quaternion.Euler(0f, target.eulerAngles.y, 0f));
if (_controller != null) _controller.enabled = ccWasEnabled;
_playerVelocity = Vector3.zero; // 텔레포트 직후 누적 낙하속도 튐 방지
}
// 일어서기: 높이 복원 + 이동 잠금 해제.
public void StandUp()
{
if (!IsSitting) return;
IsSitting = false;
SetHeight(_standingHeight);
if (_moveProvider != null) _moveProvider.enabled = true; // 이동 잠금 해제
}
private void Update()
{
// 바닥 체크 및 Y축 속도 초기화

View File

@@ -0,0 +1,42 @@
using UnityEngine;
// 스네어 위치의 "트리거 콜라이더"에 붙인다.
// VR 스틱 끝(RhythmStickTip)이 이 트리거에 들어오면 RhythmManager.OnPlayerInput()을 호출한다.
// → 키보드 입력과 완전히 동일한 진입점이라 판정/효과음/노트 소비는 RhythmManager가 그대로 처리한다.
[RequireComponent(typeof(Collider))]
public class RhythmDrumPad : MonoBehaviour
{
[SerializeField] private RhythmManager _manager;
[Tooltip("이 속력(m/s) 미만으로 닿으면 무시. 스틱이 패드에 얹히거나 미세하게 떨릴 때 오발 방지. 0이면 비활성")]
[SerializeField] private float _minHitSpeed = 0.5f;
[Tooltip("한 번 타격 후 이 시간(초) 동안 재타격 무시. 트리거 체류·경계 떨림으로 인한 중복 입력 방지")]
[SerializeField] private float _hitCooldown = 0.1f;
private float _lastHitTime = float.NegativeInfinity;
// 인스펙터에서 콜라이더를 트리거로 바꾸는 걸 깜빡하기 쉬워, 컴포넌트 추가 시 자동으로 켜 준다.
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
private void OnTriggerEnter(Collider other)
{
if (_manager == null) return;
// 닿은 게 스틱 끝인지 식별 (콜라이더가 자식이어도 부모 쪽까지 탐색)
RhythmStickTip tip = other.GetComponentInParent<RhythmStickTip>();
if (tip == null) return;
// 너무 약한 접촉(얹힘/떨림)은 무시
if (_minHitSpeed > 0f && tip.Speed < _minHitSpeed) return;
// 짧은 시간 내 중복 타격 무시
if (Time.time - _lastHitTime < _hitCooldown) return;
_lastHitTime = Time.time;
_manager.OnPlayerInput(); // 키 입력과 동일 경로 → 가장 가까운 노트 판정
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6b6ae15947930d41b86403ced8cea59

View File

@@ -0,0 +1,26 @@
using UnityEngine;
// VR 스틱 끝에 붙이는 마커 + 속도 추적기.
// 드럼 패드(RhythmDrumPad)가 "닿은 게 스틱인지" 이 컴포넌트로 식별하고,
// 타격 속력을 읽어 패드에 살짝 얹히거나 떨리는 약한 접촉을 무시할 수 있게 한다.
[DisallowMultipleComponent]
public class RhythmStickTip : MonoBehaviour
{
public float Speed { get; private set; } // 월드 기준 이동 속력(m/s)
private Vector3 _lastPos;
private void OnEnable()
{
_lastPos = transform.position; // 활성화 직후 첫 프레임 속력 튐 방지
Speed = 0f;
}
private void Update()
{
Vector3 pos = transform.position;
if (Time.deltaTime > 0f)
Speed = (pos - _lastPos).magnitude / Time.deltaTime;
_lastPos = pos;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73ace7cbbacfff1498caf66704871f48

View File

@@ -0,0 +1,56 @@
using TMPro;
using UnityEngine;
// 시작 버튼 직후 VR 준비용 카운트다운(5..1, GO!) 표시. RhythmManager 이벤트만 구독하고 표시만 한다.
public class RhythmCountdownHud : MonoBehaviour
{
[SerializeField] private RhythmManager _manager;
[SerializeField] private GameObject _root; // 카운트다운 표시 루트 (카운트 동안만 켬)
[SerializeField] private TMP_Text _countText; // 큰 숫자 / GO! 텍스트
[SerializeField] private string _goText = "GO!";
[SerializeField] private float _goHideDelay = 0.6f; // GO! 표시 후 자동으로 숨기는 시간(초)
private void Awake()
{
if (_root != null) _root.SetActive(false); // 시작 전엔 숨김
}
private void OnEnable()
{
if (_manager == null) return;
_manager.OnCountdownStarted += HandleStarted;
_manager.OnCountdownTick += HandleTick;
_manager.OnCountdownFinished += HandleFinished;
}
private void OnDisable()
{
if (_manager == null) return;
_manager.OnCountdownStarted -= HandleStarted;
_manager.OnCountdownTick -= HandleTick;
_manager.OnCountdownFinished -= HandleFinished;
}
private void HandleStarted()
{
CancelInvoke(nameof(Hide));
if (_root != null) _root.SetActive(true); // 카운트다운 시작 시 표시
}
private void HandleTick(int secondsLeft)
{
if (_countText != null) _countText.text = secondsLeft.ToString();
}
private void HandleFinished()
{
if (_countText != null) _countText.text = _goText; // GO! 잠깐 표시 후 숨김
CancelInvoke(nameof(Hide));
Invoke(nameof(Hide), _goHideDelay);
}
private void Hide()
{
if (_root != null) _root.SetActive(false);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2188bb7711648514a804e0581ef52b50