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 + +

유틸

@@ -1002,6 +1004,13 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette [SerializeField] private GameEvent onLevelComplete; [SerializeField] private TMP_Text countdownText; + // Beat Saber 4열/3행 좌표를 VRBeatsKit PlayZone 상대 좌표로 매핑하는 값 + // 기존 0.25 간격은 큐브 실제 폭보다 좁아 인접 라인이 가로로 겹쳤다 + private const float LaneSpacing = 0.42f; + private const float LayerSpacing = 0.38f; + private const float HorizontalCenter = 1.5f; + private const float VerticalCenter = 1f; + private AudioManager _audio; // VRBeatsKit AudioManager: 실제 AudioSource 래핑 // static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다 @@ -1009,8 +1018,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette private void Start() { - // FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출) - _audio = FindObjectOfType<AudioManager>(); + // FindFirstObjectByType: Unity 6 기준 FindObjectOfType 대체 API + _audio = FindFirstObjectByType<AudioManager>(); StartCoroutine(LoadAndPlay()); } @@ -1047,8 +1056,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette // File.ReadAllText + JsonUtility.FromJson = 동기 파싱. 파일이 작으므로 문제없음 MapData map = JsonUtility.FromJson<MapData>(File.ReadAllText(mapPath)); if (map?.target == null) { Debug.LogError("Map parse failed"); yield break; } - // time 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리 - map.target.Sort((a, b) => a.time.CompareTo(b.time)); + // time → position → lineLayer 순 정렬. 같은 시간대 노트도 처리 순서가 안정적이다 + map.target.Sort(CompareNotes); // ── 카운트다운 → 음악 시작 → 스폰 루프 ────────────────── yield return StartCoroutine(Countdown()); @@ -1096,11 +1105,10 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette private void SpawnNote(NoteData note) { - // Beat Saber 4열 그리드 → 월드 X 좌표 선형 매핑 - // 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭) - float x = -0.375f + note.position * 0.25f; - // 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위) - float y = -0.333f + note.lineLayer * 0.333f; + // Beat Saber 4열/3행 그리드 → VRBeatsKit 상대 좌표 + // 현재 X: -0.63, -0.21, 0.21, 0.63. 인접 큐브의 가로 겹침을 피하기 위한 폭 + float x = MapLaneX(note.position); + float y = MapLayerY(note.lineLayer); // ★ 핵심: travelTimeOverride 계산 // 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남 @@ -1125,6 +1133,28 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette VR_BeatManager.instance.Spawn(cubePrefab, info); } + // 같은 time을 가진 노트가 많아도 정렬 결과가 매번 같도록 비교 기준을 명시 + private static int CompareNotes(NoteData a, NoteData b) + { + int timeCompare = a.time.CompareTo(b.time); + if (timeCompare != 0) return timeCompare; + int positionCompare = a.position.CompareTo(b.position); + if (positionCompare != 0) return positionCompare; + return a.lineLayer.CompareTo(b.lineLayer); + } + + 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; + } + // ── cutDirection 조회 테이블 ────────────────────────────── // Beat Saber 숫자(0-8) → VRBeatsKit Direction enum // if-else 8개 대신 배열 인덱스로 O(1) 변환. static readonly = 앱 수명 동안 1번만 할당 @@ -1160,7 +1190,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette

AudioManager.cs (VRBeatsKit)

-

VRBeatsKit 내장 오디오 관리자. 우리 코드에서 PlayClip()CurrentTime만 추가했다.

+

VRBeatsKit 내장 오디오 관리자. PlayScheduled()AudioSettings.dspTime 기준 시간을 사용해 음악/노트 싱크 흔들림을 줄인다.

VRBeatsKit/Scripts/Core/AudioManager.cs
 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;
+            }
+        }
     }
 }
 
@@ -1777,7 +1832,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette if (_cardFont != null) tTmp.font = _cardFont; tTmp.text = song.title; tTmp.fontSize = 5f; tTmp.color = Color.white; tTmp.overflowMode = TextOverflowModes.Overflow; // 영역 넘어도 잘리지 않음 (RectMask2D가 처리) - tTmp.enableWordWrapping = false; // 줄바꿈 금지 → 한 줄로 + tTmp.textWrappingMode = TextWrappingModes.NoWrap; // Unity 6/TMP 최신 API. 줄바꿈 금지 titleGO.AddComponent<MarqueeText>(); // 텍스트가 컨테이너보다 길면 자동 스크롤 // ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사 @@ -1988,6 +2043,129 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
+ +
+
+

VRPointerController.cs

+

VR 컨트롤러에서 직접 World Space UI 버튼을 hover/click 처리하는 런타임 포인터. 게임오버 Back/Retry와 SongCreator UI 클릭 안정화용.

+
+
핵심 의도

XR UI 모듈의 씬별 설정에 의존하지 않고, 컨트롤러 forward 레이와 Unity UI Selectable을 직접 교차 검사한다. 클릭은 ExecuteEventsButton.onClick.Invoke()를 함께 호출한다.

+
VRPointerController.cs
+[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) { ... }
+}
+
+
+ + +
+
+

VRPointerSetup.cs

+

모든 씬에서 자동 실행되는 포인터 주입기. 컨트롤러/손 오브젝트를 찾아 VRPointerController를 붙인다.

+
+
VRPointerSetup.cs
+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;
+        }
+    }
+}
+
+
+
@@ -2008,7 +2186,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void AutoCreate() { - if (FindObjectOfType<DesktopUIMode>() != null) return; // 이미 있으면 스킵 + if (FindFirstObjectByType<DesktopUIMode>() != null) return; // 이미 있으면 스킵 new GameObject("[DesktopUIMode]").AddComponent<DesktopUIMode>(); } @@ -2073,7 +2251,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette if (cam == null) // main 카메라 없으면 활성 카메라 중 첫 번째 foreach (var c in FindObjectsByType<Camera>(FindObjectsSortMode.None)) if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad") { cam = c; break; } - cam ??= FindObjectOfType<Camera>(); // 최후의 수단 + cam ??= FindFirstObjectByType<Camera>(); // 최후의 수단 if (cam == null) return; foreach (var canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None)) diff --git a/code_review.html b/html/code_review.html similarity index 84% rename from code_review.html rename to html/code_review.html index 955562a..3f21e28 100644 --- a/code_review.html +++ b/html/code_review.html @@ -316,6 +316,7 @@ NasPublisher.cs DownloadManager.cs SongController.cs + VR UI 포인터 SongSelectManager.cs @@ -333,13 +334,14 @@ @@ -359,7 +361,7 @@

Game 씬

-

캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.

+

캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.

@@ -416,11 +418,13 @@ Application.temporaryCachePath/beatsaber/

스크립트 의존 관계

- + + +
스크립트의존 대상의존 방식
SongControllerGameSession, AudioManager, VR_BeatManagerstatic / FindObjectOfType / singleton
SongControllerGameSession, AudioManager, VR_BeatManagerstatic / FindFirstObjectByType / singleton
SongSelectManagerDownloadManager, SongDetailPanel, SongLibrarySerializeField / singleton
NasPublisherBeatSageConverterstatic class 직접 호출
BeatSageUploaderBeatSageConverter, NoteDatastatic class 직접 호출
DownloadManagerNoteData (SongInfo)파라미터
VRPointerSetupVRPointerController, SceneManagerRuntimeInitializeOnLoadMethod / sceneLoaded
VRPointerControllerSelectable, EventSystem, XR InputDevice직접 Ray/Rect 교차 + ExecuteEvents
@@ -752,6 +756,38 @@ list ??= new SongsList { version } +

오디오 싱크 — DSP 기준 예약 재생

+
+
AudioManager.cs — PlayClipScheduled()
+
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

위치 계산

SongController.cs
-
// 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으로 벌어져 가로 겹침을 피한다.

+
+ + + +
+

VRPointerController / VRPointerSetup — VR UI 클릭 안정화

+

게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.

+ +

구조

+
+
VRPointerSetup
BeforeSceneLoad 자동 생성
+ +
SceneManager.sceneLoaded
+ +
Controller/Hand + LineRenderer 탐색
+ +
VRPointerController 주입
+
+ +
+
VRPointerController.cs — 클릭 처리
+
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 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.

@@ -875,6 +972,16 @@ btn.onClick.AddListener(() => OnCardCli SongController.CutDirMap, BeatSageUploader.DiffNames

static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.

+ + Deterministic Sort + SongController.CompareNotes() +

time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.

+ + + Runtime Injection + VRPointerSetup +

씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.

+ Upsert NasPublisher.PatchSongsJson() @@ -938,7 +1045,26 @@ btn.onClick.AddListener(() => OnCardCli using (var req = UnityWebRequest.Get(url)) { ... }
-

5. Unity ?? 연산자 주의사항

+

5. Unity 6 API 전환

+
+
deprecated API 정리
+
// 이전
+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와 미사용 필드 경고까지 정리되어 있다.

+
+ +

6. Unity ?? 연산자 주의사항

SaberGlow.cs 버그 사례
// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함
@@ -1010,12 +1136,17 @@ btn.onClick.AddListener(() => OnCardCli
       
  • SongCreator 씬 — 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 생성 직후 첫 재생 싱크/로드 로그 추가 검증