diff --git a/annotated_code.html b/html/annotated_code.html similarity index 92% rename from annotated_code.html rename to html/annotated_code.html index ffdf10b..04aa49f 100644 --- a/annotated_code.html +++ b/html/annotated_code.html @@ -64,6 +64,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette + +
VRBeatsKit 내장 오디오 관리자. 우리 코드에서 PlayClip()과 CurrentTime만 추가했다.
VRBeatsKit 내장 오디오 관리자. PlayScheduled()와 AudioSettings.dspTime 기준 시간을 사용해 음악/노트 싱크 흔들림을 줄인다.
namespace VRBeats @@ -1174,6 +1204,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette [SerializeField] private float fadeOutTime = 4.0f; // 피치 페이드 시간 private AudioSource audioSource = null; + private double scheduledDspStartTime = -1.0; + private bool hasScheduledClip = false; private void Start() { @@ -1212,17 +1244,40 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette public void SetAudioMixerPitch(float value) => audioSource.outputAudioMixerGroup.audioMixer.SetFloat("Pitch", value); - // ★ 우리가 추가한 메서드 ───────────────────────────────── // SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출 + // 내부적으로 예약 재생을 사용해 프레임 상태와 무관한 DSP 기준 시작 시간을 만든다 public void PlayClip(AudioClip clip) { - audioSource.clip = clip; // 재생할 클립 교체 - audioSource.Play(); // 즉시 재생 시작 + PlayClipScheduled(clip); } - // 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌다 - // audioSource가 null이면 0 반환 (씬 초기화 중 안전) - public float CurrentTime => audioSource != null ? audioSource.time : 0f; + // AudioSource.Play()는 호출 프레임과 오디오 스레드 타이밍에 따라 시작 오차가 생길 수 있다 + // PlayScheduled는 DSP 시간에 맞춰 재생 예약하므로 노트 스폰 기준을 더 안정적으로 잡을 수 있다 + public double PlayClipScheduled(AudioClip clip, double delaySeconds = 0.1) + { + ResetThisComponent(); + audioSource.Stop(); + audioSource.clip = clip; + audioSource.time = 0.0f; + + scheduledDspStartTime = AudioSettings.dspTime + delaySeconds; + hasScheduledClip = true; + audioSource.PlayScheduled(scheduledDspStartTime); + + return scheduledDspStartTime; + } + + // 현재 재생 위치(초). 예약 재생 중이면 AudioSource.time 대신 DSP 시간 차이를 사용한다 + // 예약 시작 전에는 음수일 수 있으나 SpawnRoutine의 spawnAt은 0 이상이라 조기 스폰되지 않는다 + public float CurrentTime + { + get + { + if (audioSource == null) return 0.0f; + if (hasScheduledClip) return (float)(AudioSettings.dspTime - scheduledDspStartTime); + return audioSource.time; + } + } } }
VR 컨트롤러에서 직접 World Space UI 버튼을 hover/click 처리하는 런타임 포인터. 게임오버 Back/Retry와 SongCreator UI 클릭 안정화용.
+XR UI 모듈의 씬별 설정에 의존하지 않고, 컨트롤러 forward 레이와 Unity UI Selectable을 직접 교차 검사한다. 클릭은 ExecuteEvents와 Button.onClick.Invoke()를 함께 호출한다.
+[RequireComponent(typeof(LineRenderer))] +public class VRPointerController : MonoBehaviour +{ + [SerializeField] private bool isRightHand = true; + [SerializeField] private float maxDistance = 50f; + + private LineRenderer _line; + private Selectable _currentHover; + private bool _prevTrigger, _prevPrimary; + + private void Awake() + { + _line = GetComponent<LineRenderer>(); + _line.positionCount = 2; + _line.startWidth = 0.005f; + _line.endWidth = 0.001f; + _line.useWorldSpace = true; + } + + private void Update() + { + bool trigger = GetButton(CommonUsages.triggerButton); + bool primary = GetButton(CommonUsages.primaryButton); + bool triggerDown = trigger && !_prevTrigger; + bool primaryDown = primary && !_prevPrimary; + _prevTrigger = trigger; + _prevPrimary = primary; + + var ray = new Ray(transform.position, transform.forward); + float hitDist = maxDistance; + Selectable hit = FindSelectableUnderRay(ray, ref hitDist); + UpdateHoverState(hit); + + // 검지 트리거 또는 A/X 버튼으로 현재 hover된 Selectable 클릭 + if ((triggerDown || primaryDown) && _currentHover != null) + Click(_currentHover); + + DrawLine(hitDist); + } + + private static void Click(Selectable sel) + { + var es = EventSystem.current; + if (es == null) return; + + var eventData = new PointerEventData(es); + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerDownHandler); + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerUpHandler); + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerClickHandler); + + // 일부 Button은 ExecuteEvents만으로 onClick까지 가지 않는 경우가 있어 명시 호출 + var btn = sel.GetComponent<Button>(); + if (btn != null) btn.onClick.Invoke(); + } + + // Selectable의 RectTransform 월드 코너와 레이/평면 교차를 직접 계산 + private static Selectable FindSelectableUnderRay(Ray ray, ref float maxDist) { ... } + private bool GetButton(InputFeatureUsage<bool> usage) { ... } +} +
모든 씬에서 자동 실행되는 포인터 주입기. 컨트롤러/손 오브젝트를 찾아 VRPointerController를 붙인다.
+public class VRPointerSetup : MonoBehaviour +{ + private static VRPointerSetup instance; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void AutoInject() + { + if (instance != null) return; + new GameObject("[VRPointerSetup]").AddComponent<VRPointerSetup>(); + } + + private void Awake() + { + if (instance != null && instance != this) { Destroy(gameObject); return; } + instance = this; + DontDestroyOnLoad(gameObject); + } + + private void OnEnable() => SceneManager.sceneLoaded += OnSceneLoaded; + private void OnDisable() => SceneManager.sceneLoaded -= OnSceneLoaded; + + private static void SetupScene(Scene scene) + { + bool isGameScene = scene.name == "Game"; + SetupControllers(disabledByDefault: isGameScene); + } + + // Game 씬에서는 게임오버 전까지 포인터 비활성. VR_InteractorController가 GameOver 때 켠다 + private static void SetupControllers(bool disabledByDefault) + { + foreach (var go in FindObjectsByType<GameObject>(FindObjectsSortMode.None)) + { + bool isRight = go.name.Contains("Right"); + bool isLeft = go.name.Contains("Left"); + if (!isRight && !isLeft) continue; + if (go.GetComponent<LineRenderer>() == null) continue; + if (go.GetComponent<VRPointerController>() != null) continue; + + var pointer = go.AddComponent<VRPointerController>(); + if (disabledByDefault) pointer.enabled = false; + } + } +} +
Unity + Meta Quest 기반 커스텀 비트 세이버 클론 | 학습용 코드 리뷰 문서
+Unity 6000.3.12f1 + Meta Quest 기반 커스텀 비트 세이버 클론 | 최신 코드 리뷰 문서
캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.
+캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.
| 스크립트 | 의존 대상 | 의존 방식 |
|---|---|---|
| SongController | GameSession, AudioManager, VR_BeatManager | static / FindObjectOfType / singleton |
| SongController | GameSession, AudioManager, VR_BeatManager | static / FindFirstObjectByType / singleton |
| SongSelectManager | DownloadManager, SongDetailPanel, SongLibrary | SerializeField / singleton |
| NasPublisher | BeatSageConverter | static class 직접 호출 |
| BeatSageUploader | BeatSageConverter, NoteData | static class 직접 호출 |
| DownloadManager | NoteData (SongInfo) | 파라미터 |
| VRPointerSetup | VRPointerController, SceneManager | RuntimeInitializeOnLoadMethod / sceneLoaded |
| VRPointerController | Selectable, EventSystem, XR InputDevice | 직접 Ray/Rect 교차 + ExecuteEvents |
public double PlayClipScheduled(AudioClip clip, double delaySeconds = 0.1) +{ + audioSource.Stop(); + audioSource.clip = clip; + audioSource.time = 0.0f; + + scheduledDspStartTime = AudioSettings.dspTime + delaySeconds; + hasScheduledClip = true; + audioSource.PlayScheduled(scheduledDspStartTime); + + return scheduledDspStartTime; +} + +public float CurrentTime +{ + get + { + if (hasScheduledClip) + return (float)(AudioSettings.dspTime - scheduledDspStartTime); + return audioSource.time; + } +}+
AudioSource.Play() 대신 PlayScheduled()를 사용해 음악 시작 시점을 DSP 시간에 고정했다. 노트 스폰 기준도 AudioSettings.dspTime 차이로 계산하므로 프레임 상태에 따른 싱크 흔들림이 줄었다.
@@ -784,9 +820,70 @@ list ??= new SongsList { version
// Beat Saber 그리드 → 월드 좌표 선형 매핑 -float x = -0.375f + note.position * 0.25f; // 열 0→-0.375, 1→-0.125, 2→0.125, 3→0.375 -float y = -0.333f + note.lineLayer * 0.333f; // 행 0→-0.333, 1→0, 2→0.333+
private const float LaneSpacing = 0.42f; +private const float LayerSpacing = 0.38f; +private const float HorizontalCenter = 1.5f; +private const float VerticalCenter = 1f; + +private static float MapLaneX(int position) +{ + int lane = Mathf.Clamp(position, 0, 3); + return (lane - HorizontalCenter) * LaneSpacing; +} + +private static float MapLayerY(int lineLayer) +{ + int layer = Mathf.Clamp(lineLayer, 0, 2); + return (layer - VerticalCenter) * LayerSpacing; +}+
기존 라인 간격은 0.25였고 큐브 실제 폭은 약 0.36이라 인접 라인이 겹칠 수밖에 없었다. 현재 X 좌표는 대략 -0.63, -0.21, 0.21, 0.63으로 벌어져 가로 겹침을 피한다.
게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.
+ +private static void Click(Selectable sel) +{ + var es = EventSystem.current; + var eventData = new PointerEventData(es); + + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerDownHandler); + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerUpHandler); + ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerClickHandler); + + var btn = sel.GetComponent<Button>(); + if (btn != null) btn.onClick.Invoke(); +}+
XR UI 모듈 설정에 의존하지 않고 Selectable을 직접 Ray/Rect 교차 검사하는 방식이라 씬별 Raycaster 설정 차이에 강하다. 단, 커스텀 Selectable이나 비사각형 UI가 늘어나면 GraphicRaycaster 기반으로 재검토할 수 있다.
+Game 씬에서는 포인터가 기본 비활성이고 게임오버 시 VR_InteractorController를 통해 활성화된다. Quest에서 Back/Retry, SongCreator 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.
static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.
time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.
씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.
// 이전 +FindObjectOfType<AudioManager>(); +FindObjectsOfType<Canvas>(); +tTmp.enableWordWrapping = false; + +// 현재 +FindFirstObjectByType<AudioManager>(); +FindObjectsByType<Canvas>(FindObjectsSortMode.None); +tTmp.textWrappingMode = TextWrappingModes.NoWrap;+
dotnet build VRBeatSaber.slnx --no-incremental 기준 경고 0개, 오류 0개다. VRBeatsKit 내부 deprecated API와 미사용 필드 경고까지 정리되어 있다.
// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함 @@ -1010,12 +1136,17 @@ btn.onClick.AddListener(() => OnCardCliSongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인 SongSelect 씬 — 카드 목록 + 다운로드 + 플레이 Game 씬 — SongController, 카운트다운, 큐브 스폰 -travelTimeOverride — 동시 노트 보정 -Git remote 설정 (Synology NAS) -Game 씬 ScoreManager / ScoreHUD 연결 -Game 씬 ResultsPanel 연결 (CLEAR/FAILED, 랭크) -VR 기기 실제 플레이 테스트 -targetTravelTime 1.8 플레이 후 미세 조정 +travelTimeOverride — 동시 노트 도착 타이밍 보정 +AudioManager — DSP 기준 PlayScheduled 싱크 개선 +VRPointerController/Setup — VR UI hover/click 처리 +GameOver Back/Retry 버튼 스크립트 참조 복구 +큐브 가로 간격 보정 — 인접 라인 겹침 방지 +C# 빌드 경고 0개 정리 +Git remote 설정 및 master/main 최신화 +Quest 실기에서 GameOver Back/Retry 클릭 확인 +Quest 실기에서 SongCreator UI 클릭 확인 +큐브 간격, 세이버 각도, targetTravelTime 1.8 체감 조정 +SongCreator 생성 직후 첫 재생 싱크/로드 로그 추가 검증