docs: update html code review docs
This commit is contained in:
@@ -64,6 +64,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<button class="tab-btn" onclick="show('detail')">SongDetailPanel.cs</button>
|
||||
<button class="tab-btn" onclick="show('selectmgr')">SongSelectManager.cs</button>
|
||||
<button class="tab-btn" onclick="show('marquee')">MarqueeText.cs</button>
|
||||
<button class="tab-btn" onclick="show('vrpointer')">VRPointerController.cs</button>
|
||||
<button class="tab-btn" onclick="show('vrptrsetup')">VRPointerSetup.cs</button>
|
||||
<h2>유틸</h2>
|
||||
<button class="tab-btn" onclick="show('songlibrary')">SongLibrary.cs</button>
|
||||
<button class="tab-btn" onclick="show('desktop')">DesktopUIMode.cs</button>
|
||||
@@ -1002,6 +1004,13 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">GameEvent</span> onLevelComplete;
|
||||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Text</span> countdownText;
|
||||
|
||||
<span class="ann">// Beat Saber 4열/3행 좌표를 VRBeatsKit PlayZone 상대 좌표로 매핑하는 값</span>
|
||||
<span class="ann">// 기존 0.25 간격은 큐브 실제 폭보다 좁아 인접 라인이 가로로 겹쳤다</span>
|
||||
<span class="kw">private const float</span> LaneSpacing = <span class="nm">0.42f</span>;
|
||||
<span class="kw">private const float</span> LayerSpacing = <span class="nm">0.38f</span>;
|
||||
<span class="kw">private const float</span> HorizontalCenter = <span class="nm">1.5f</span>;
|
||||
<span class="kw">private const float</span> VerticalCenter = <span class="nm">1f</span>;
|
||||
|
||||
<span class="kw">private</span> <span class="ty">AudioManager</span> _audio; <span class="ann">// VRBeatsKit AudioManager: 실제 AudioSource 래핑</span>
|
||||
|
||||
<span class="ann">// static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다</span>
|
||||
@@ -1009,8 +1018,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||||
{
|
||||
<span class="ann">// FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출)</span>
|
||||
_audio = <span class="fn">FindObjectOfType</span><<span class="ty">AudioManager</span>>();
|
||||
<span class="ann">// FindFirstObjectByType: Unity 6 기준 FindObjectOfType 대체 API</span>
|
||||
_audio = <span class="fn">FindFirstObjectByType</span><<span class="ty">AudioManager</span>>();
|
||||
<span class="fn">StartCoroutine</span>(<span class="fn">LoadAndPlay</span>());
|
||||
}
|
||||
|
||||
@@ -1047,8 +1056,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<span class="ann">// File.ReadAllText + JsonUtility.FromJson = 동기 파싱. 파일이 작으므로 문제없음</span>
|
||||
<span class="ty">MapData</span> map = JsonUtility.<span class="fn">FromJson</span><<span class="ty">MapData</span>>(File.<span class="fn">ReadAllText</span>(mapPath));
|
||||
<span class="kw">if</span> (map?.target == <span class="kw">null</span>) { Debug.<span class="fn">LogError</span>(<span class="st">"Map parse failed"</span>); <span class="kw">yield break</span>; }
|
||||
<span class="ann">// time 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리</span>
|
||||
map.target.<span class="fn">Sort</span>((a, b) => a.time.<span class="fn">CompareTo</span>(b.time));
|
||||
<span class="ann">// time → position → lineLayer 순 정렬. 같은 시간대 노트도 처리 순서가 안정적이다</span>
|
||||
map.target.<span class="fn">Sort</span>(<span class="fn">CompareNotes</span>);
|
||||
|
||||
<span class="ann">// ── 카운트다운 → 음악 시작 → 스폰 루프 ──────────────────</span>
|
||||
<span class="kw">yield return</span> <span class="fn">StartCoroutine</span>(<span class="fn">Countdown</span>());
|
||||
@@ -1096,11 +1105,10 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">SpawnNote</span>(<span class="ty">NoteData</span> note)
|
||||
{
|
||||
<span class="ann">// Beat Saber 4열 그리드 → 월드 X 좌표 선형 매핑</span>
|
||||
<span class="ann">// 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭)</span>
|
||||
<span class="kw">float</span> x = <span class="nm">-0.375f</span> + note.position * <span class="nm">0.25f</span>;
|
||||
<span class="ann">// 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위)</span>
|
||||
<span class="kw">float</span> y = <span class="nm">-0.333f</span> + note.lineLayer * <span class="nm">0.333f</span>;
|
||||
<span class="ann">// Beat Saber 4열/3행 그리드 → VRBeatsKit 상대 좌표</span>
|
||||
<span class="ann">// 현재 X: -0.63, -0.21, 0.21, 0.63. 인접 큐브의 가로 겹침을 피하기 위한 폭</span>
|
||||
<span class="kw">float</span> x = <span class="fn">MapLaneX</span>(note.position);
|
||||
<span class="kw">float</span> y = <span class="fn">MapLayerY</span>(note.lineLayer);
|
||||
|
||||
<span class="ann">// ★ 핵심: travelTimeOverride 계산</span>
|
||||
<span class="ann">// 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남</span>
|
||||
@@ -1125,6 +1133,28 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<span class="ty">VR_BeatManager</span>.instance.<span class="fn">Spawn</span>(cubePrefab, info);
|
||||
}
|
||||
|
||||
<span class="ann">// 같은 time을 가진 노트가 많아도 정렬 결과가 매번 같도록 비교 기준을 명시</span>
|
||||
<span class="kw">private static int</span> <span class="fn">CompareNotes</span>(<span class="ty">NoteData</span> a, <span class="ty">NoteData</span> b)
|
||||
{
|
||||
<span class="kw">int</span> timeCompare = a.time.<span class="fn">CompareTo</span>(b.time);
|
||||
<span class="kw">if</span> (timeCompare != <span class="nm">0</span>) <span class="kw">return</span> timeCompare;
|
||||
<span class="kw">int</span> positionCompare = a.position.<span class="fn">CompareTo</span>(b.position);
|
||||
<span class="kw">if</span> (positionCompare != <span class="nm">0</span>) <span class="kw">return</span> positionCompare;
|
||||
<span class="kw">return</span> a.lineLayer.<span class="fn">CompareTo</span>(b.lineLayer);
|
||||
}
|
||||
|
||||
<span class="kw">private static float</span> <span class="fn">MapLaneX</span>(<span class="kw">int</span> position)
|
||||
{
|
||||
<span class="kw">int</span> lane = Mathf.<span class="fn">Clamp</span>(position, <span class="nm">0</span>, <span class="nm">3</span>);
|
||||
<span class="kw">return</span> (lane - HorizontalCenter) * LaneSpacing;
|
||||
}
|
||||
|
||||
<span class="kw">private static float</span> <span class="fn">MapLayerY</span>(<span class="kw">int</span> lineLayer)
|
||||
{
|
||||
<span class="kw">int</span> layer = Mathf.<span class="fn">Clamp</span>(lineLayer, <span class="nm">0</span>, <span class="nm">2</span>);
|
||||
<span class="kw">return</span> (layer - VerticalCenter) * LayerSpacing;
|
||||
}
|
||||
|
||||
<span class="ann">// ── cutDirection 조회 테이블 ──────────────────────────────</span>
|
||||
<span class="ann">// Beat Saber 숫자(0-8) → VRBeatsKit Direction enum</span>
|
||||
<span class="ann">// if-else 8개 대신 배열 인덱스로 O(1) 변환. static readonly = 앱 수명 동안 1번만 할당</span>
|
||||
@@ -1160,7 +1190,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<div id="p-audiomgr" class="panel">
|
||||
<div class="file-header">
|
||||
<h1>AudioManager.cs <span style="font-size:14px;font-weight:400;color:var(--mu)">(VRBeatsKit)</span></h1>
|
||||
<p>VRBeatsKit 내장 오디오 관리자. 우리 코드에서 <code>PlayClip()</code>과 <code>CurrentTime</code>만 추가했다.</p>
|
||||
<p>VRBeatsKit 내장 오디오 관리자. <code>PlayScheduled()</code>와 <code>AudioSettings.dspTime</code> 기준 시간을 사용해 음악/노트 싱크 흔들림을 줄인다.</p>
|
||||
</div>
|
||||
<div class="cw"><div class="ch"><span>VRBeatsKit/Scripts/Core/AudioManager.cs</span></div><pre>
|
||||
<span class="kw">namespace</span> <span class="ty">VRBeats</span>
|
||||
@@ -1174,6 +1204,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
[<span class="ty">SerializeField</span>] <span class="kw">private float</span> fadeOutTime = <span class="nm">4.0f</span>; <span class="ann">// 피치 페이드 시간</span>
|
||||
|
||||
<span class="kw">private</span> <span class="ty">AudioSource</span> audioSource = <span class="kw">null</span>;
|
||||
<span class="kw">private double</span> scheduledDspStartTime = -<span class="nm">1.0</span>;
|
||||
<span class="kw">private bool</span> hasScheduledClip = <span class="kw">false</span>;
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||||
{
|
||||
@@ -1212,17 +1244,40 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<span class="kw">public void</span> <span class="fn">SetAudioMixerPitch</span>(<span class="kw">float</span> value)
|
||||
=> audioSource.outputAudioMixerGroup.audioMixer.<span class="fn">SetFloat</span>(<span class="st">"Pitch"</span>, value);
|
||||
|
||||
<span class="ann">// ★ 우리가 추가한 메서드 ─────────────────────────────────</span>
|
||||
<span class="ann">// SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출</span>
|
||||
<span class="ann">// 내부적으로 예약 재생을 사용해 프레임 상태와 무관한 DSP 기준 시작 시간을 만든다</span>
|
||||
<span class="kw">public void</span> <span class="fn">PlayClip</span>(<span class="ty">AudioClip</span> clip)
|
||||
{
|
||||
audioSource.clip = clip; <span class="ann">// 재생할 클립 교체</span>
|
||||
audioSource.<span class="fn">Play</span>(); <span class="ann">// 즉시 재생 시작</span>
|
||||
<span class="fn">PlayClipScheduled</span>(clip);
|
||||
}
|
||||
|
||||
<span class="ann">// 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌다</span>
|
||||
<span class="ann">// audioSource가 null이면 0 반환 (씬 초기화 중 안전)</span>
|
||||
<span class="kw">public float</span> CurrentTime => audioSource != <span class="kw">null</span> ? audioSource.time : <span class="nm">0f</span>;
|
||||
<span class="ann">// AudioSource.Play()는 호출 프레임과 오디오 스레드 타이밍에 따라 시작 오차가 생길 수 있다</span>
|
||||
<span class="ann">// PlayScheduled는 DSP 시간에 맞춰 재생 예약하므로 노트 스폰 기준을 더 안정적으로 잡을 수 있다</span>
|
||||
<span class="kw">public double</span> <span class="fn">PlayClipScheduled</span>(<span class="ty">AudioClip</span> clip, <span class="kw">double</span> delaySeconds = <span class="nm">0.1</span>)
|
||||
{
|
||||
<span class="fn">ResetThisComponent</span>();
|
||||
audioSource.<span class="fn">Stop</span>();
|
||||
audioSource.clip = clip;
|
||||
audioSource.time = <span class="nm">0.0f</span>;
|
||||
|
||||
scheduledDspStartTime = <span class="ty">AudioSettings</span>.dspTime + delaySeconds;
|
||||
hasScheduledClip = <span class="kw">true</span>;
|
||||
audioSource.<span class="fn">PlayScheduled</span>(scheduledDspStartTime);
|
||||
|
||||
<span class="kw">return</span> scheduledDspStartTime;
|
||||
}
|
||||
|
||||
<span class="ann">// 현재 재생 위치(초). 예약 재생 중이면 AudioSource.time 대신 DSP 시간 차이를 사용한다</span>
|
||||
<span class="ann">// 예약 시작 전에는 음수일 수 있으나 SpawnRoutine의 spawnAt은 0 이상이라 조기 스폰되지 않는다</span>
|
||||
<span class="kw">public float</span> CurrentTime
|
||||
{
|
||||
<span class="kw">get</span>
|
||||
{
|
||||
<span class="kw">if</span> (audioSource == <span class="kw">null</span>) <span class="kw">return</span> <span class="nm">0.0f</span>;
|
||||
<span class="kw">if</span> (hasScheduledClip) <span class="kw">return</span> (<span class="kw">float</span>)(<span class="ty">AudioSettings</span>.dspTime - scheduledDspStartTime);
|
||||
<span class="kw">return</span> audioSource.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</pre></div>
|
||||
@@ -1777,7 +1832,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<span class="kw">if</span> (_cardFont != <span class="kw">null</span>) tTmp.font = _cardFont;
|
||||
tTmp.text = song.title; tTmp.fontSize = <span class="nm">5f</span>; tTmp.color = <span class="ty">Color</span>.white;
|
||||
tTmp.overflowMode = <span class="ty">TextOverflowModes</span>.Overflow; <span class="ann">// 영역 넘어도 잘리지 않음 (RectMask2D가 처리)</span>
|
||||
tTmp.enableWordWrapping = <span class="kw">false</span>; <span class="ann">// 줄바꿈 금지 → 한 줄로</span>
|
||||
tTmp.textWrappingMode = <span class="ty">TextWrappingModes</span>.NoWrap; <span class="ann">// Unity 6/TMP 최신 API. 줄바꿈 금지</span>
|
||||
titleGO.<span class="fn">AddComponent</span><<span class="ty">MarqueeText</span>>(); <span class="ann">// 텍스트가 컨테이너보다 길면 자동 스크롤</span>
|
||||
|
||||
<span class="ann">// ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사</span>
|
||||
@@ -1988,6 +2043,129 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
</pre></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════ VRPointerController.cs ══════════════════════ -->
|
||||
<div id="p-vrpointer" class="panel">
|
||||
<div class="file-header">
|
||||
<h1>VRPointerController.cs</h1>
|
||||
<p>VR 컨트롤러에서 직접 World Space UI 버튼을 hover/click 처리하는 런타임 포인터. 게임오버 Back/Retry와 SongCreator UI 클릭 안정화용.</p>
|
||||
</div>
|
||||
<div class="box box-g"><div class="lbl">핵심 의도</div><p>XR UI 모듈의 씬별 설정에 의존하지 않고, 컨트롤러 forward 레이와 Unity UI <code>Selectable</code>을 직접 교차 검사한다. 클릭은 <code>ExecuteEvents</code>와 <code>Button.onClick.Invoke()</code>를 함께 호출한다.</p></div>
|
||||
<div class="cw"><div class="ch"><span>VRPointerController.cs</span></div><pre>
|
||||
[<span class="ty">RequireComponent</span>(<span class="kw">typeof</span>(<span class="ty">LineRenderer</span>))]
|
||||
<span class="kw">public class</span> <span class="ty">VRPointerController</span> : <span class="ty">MonoBehaviour</span>
|
||||
{
|
||||
[<span class="ty">SerializeField</span>] <span class="kw">private bool</span> isRightHand = <span class="kw">true</span>;
|
||||
[<span class="ty">SerializeField</span>] <span class="kw">private float</span> maxDistance = <span class="nm">50f</span>;
|
||||
|
||||
<span class="kw">private</span> <span class="ty">LineRenderer</span> _line;
|
||||
<span class="kw">private</span> <span class="ty">Selectable</span> _currentHover;
|
||||
<span class="kw">private bool</span> _prevTrigger, _prevPrimary;
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||||
{
|
||||
_line = <span class="fn">GetComponent</span><<span class="ty">LineRenderer</span>>();
|
||||
_line.positionCount = <span class="nm">2</span>;
|
||||
_line.startWidth = <span class="nm">0.005f</span>;
|
||||
_line.endWidth = <span class="nm">0.001f</span>;
|
||||
_line.useWorldSpace = <span class="kw">true</span>;
|
||||
}
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">Update</span>()
|
||||
{
|
||||
<span class="kw">bool</span> trigger = <span class="fn">GetButton</span>(<span class="ty">CommonUsages</span>.triggerButton);
|
||||
<span class="kw">bool</span> primary = <span class="fn">GetButton</span>(<span class="ty">CommonUsages</span>.primaryButton);
|
||||
<span class="kw">bool</span> triggerDown = trigger && !_prevTrigger;
|
||||
<span class="kw">bool</span> primaryDown = primary && !_prevPrimary;
|
||||
_prevTrigger = trigger;
|
||||
_prevPrimary = primary;
|
||||
|
||||
<span class="kw">var</span> ray = <span class="kw">new</span> <span class="ty">Ray</span>(transform.position, transform.forward);
|
||||
<span class="kw">float</span> hitDist = maxDistance;
|
||||
<span class="ty">Selectable</span> hit = <span class="fn">FindSelectableUnderRay</span>(ray, <span class="kw">ref</span> hitDist);
|
||||
<span class="fn">UpdateHoverState</span>(hit);
|
||||
|
||||
<span class="ann">// 검지 트리거 또는 A/X 버튼으로 현재 hover된 Selectable 클릭</span>
|
||||
<span class="kw">if</span> ((triggerDown || primaryDown) && _currentHover != <span class="kw">null</span>)
|
||||
<span class="fn">Click</span>(_currentHover);
|
||||
|
||||
<span class="fn">DrawLine</span>(hitDist);
|
||||
}
|
||||
|
||||
<span class="kw">private static void</span> <span class="fn">Click</span>(<span class="ty">Selectable</span> sel)
|
||||
{
|
||||
<span class="kw">var</span> es = <span class="ty">EventSystem</span>.current;
|
||||
<span class="kw">if</span> (es == <span class="kw">null</span>) <span class="kw">return</span>;
|
||||
|
||||
<span class="kw">var</span> eventData = <span class="kw">new</span> <span class="ty">PointerEventData</span>(es);
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerDownHandler);
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerUpHandler);
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerClickHandler);
|
||||
|
||||
<span class="ann">// 일부 Button은 ExecuteEvents만으로 onClick까지 가지 않는 경우가 있어 명시 호출</span>
|
||||
<span class="kw">var</span> btn = sel.<span class="fn">GetComponent</span><<span class="ty">Button</span>>();
|
||||
<span class="kw">if</span> (btn != <span class="kw">null</span>) btn.onClick.<span class="fn">Invoke</span>();
|
||||
}
|
||||
|
||||
<span class="ann">// Selectable의 RectTransform 월드 코너와 레이/평면 교차를 직접 계산</span>
|
||||
<span class="kw">private static</span> <span class="ty">Selectable</span> <span class="fn">FindSelectableUnderRay</span>(<span class="ty">Ray</span> ray, <span class="kw">ref float</span> maxDist) { ... }
|
||||
<span class="kw">private bool</span> <span class="fn">GetButton</span>(<span class="ty">InputFeatureUsage</span><<span class="kw">bool</span>> usage) { ... }
|
||||
}
|
||||
</pre></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════ VRPointerSetup.cs ══════════════════════ -->
|
||||
<div id="p-vrptrsetup" class="panel">
|
||||
<div class="file-header">
|
||||
<h1>VRPointerSetup.cs</h1>
|
||||
<p>모든 씬에서 자동 실행되는 포인터 주입기. 컨트롤러/손 오브젝트를 찾아 <code>VRPointerController</code>를 붙인다.</p>
|
||||
</div>
|
||||
<div class="cw"><div class="ch"><span>VRPointerSetup.cs</span></div><pre>
|
||||
<span class="kw">public class</span> <span class="ty">VRPointerSetup</span> : <span class="ty">MonoBehaviour</span>
|
||||
{
|
||||
<span class="kw">private static</span> <span class="ty">VRPointerSetup</span> instance;
|
||||
|
||||
[<span class="ty">RuntimeInitializeOnLoadMethod</span>(<span class="ty">RuntimeInitializeLoadType</span>.BeforeSceneLoad)]
|
||||
<span class="kw">private static void</span> <span class="fn">AutoInject</span>()
|
||||
{
|
||||
<span class="kw">if</span> (instance != <span class="kw">null</span>) <span class="kw">return</span>;
|
||||
<span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"[VRPointerSetup]"</span>).<span class="fn">AddComponent</span><<span class="ty">VRPointerSetup</span>>();
|
||||
}
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||||
{
|
||||
<span class="kw">if</span> (instance != <span class="kw">null</span> && instance != <span class="kw">this</span>) { <span class="fn">Destroy</span>(gameObject); <span class="kw">return</span>; }
|
||||
instance = <span class="kw">this</span>;
|
||||
<span class="ty">DontDestroyOnLoad</span>(gameObject);
|
||||
}
|
||||
|
||||
<span class="kw">private void</span> <span class="fn">OnEnable</span>() => <span class="ty">SceneManager</span>.sceneLoaded += <span class="fn">OnSceneLoaded</span>;
|
||||
<span class="kw">private void</span> <span class="fn">OnDisable</span>() => <span class="ty">SceneManager</span>.sceneLoaded -= <span class="fn">OnSceneLoaded</span>;
|
||||
|
||||
<span class="kw">private static void</span> <span class="fn">SetupScene</span>(<span class="ty">Scene</span> scene)
|
||||
{
|
||||
<span class="kw">bool</span> isGameScene = scene.name == <span class="st">"Game"</span>;
|
||||
<span class="fn">SetupControllers</span>(disabledByDefault: isGameScene);
|
||||
}
|
||||
|
||||
<span class="ann">// Game 씬에서는 게임오버 전까지 포인터 비활성. VR_InteractorController가 GameOver 때 켠다</span>
|
||||
<span class="kw">private static void</span> <span class="fn">SetupControllers</span>(<span class="kw">bool</span> disabledByDefault)
|
||||
{
|
||||
<span class="kw">foreach</span> (<span class="kw">var</span> go <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">GameObject</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||||
{
|
||||
<span class="kw">bool</span> isRight = go.name.<span class="fn">Contains</span>(<span class="st">"Right"</span>);
|
||||
<span class="kw">bool</span> isLeft = go.name.<span class="fn">Contains</span>(<span class="st">"Left"</span>);
|
||||
<span class="kw">if</span> (!isRight && !isLeft) <span class="kw">continue</span>;
|
||||
<span class="kw">if</span> (go.<span class="fn">GetComponent</span><<span class="ty">LineRenderer</span>>() == <span class="kw">null</span>) <span class="kw">continue</span>;
|
||||
<span class="kw">if</span> (go.<span class="fn">GetComponent</span><<span class="ty">VRPointerController</span>>() != <span class="kw">null</span>) <span class="kw">continue</span>;
|
||||
|
||||
<span class="kw">var</span> pointer = go.<span class="fn">AddComponent</span><<span class="ty">VRPointerController</span>>();
|
||||
<span class="kw">if</span> (disabledByDefault) pointer.enabled = <span class="kw">false</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
</pre></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════ DesktopUIMode.cs ══════════════════════ -->
|
||||
<div id="p-desktop" class="panel">
|
||||
<div class="file-header">
|
||||
@@ -2008,7 +2186,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
[<span class="ty">RuntimeInitializeOnLoadMethod</span>(<span class="ty">RuntimeInitializeLoadType</span>.AfterSceneLoad)]
|
||||
<span class="kw">private static void</span> <span class="fn">AutoCreate</span>()
|
||||
{
|
||||
<span class="kw">if</span> (<span class="fn">FindObjectOfType</span><<span class="ty">DesktopUIMode</span>>() != <span class="kw">null</span>) <span class="kw">return</span>; <span class="ann">// 이미 있으면 스킵</span>
|
||||
<span class="kw">if</span> (<span class="fn">FindFirstObjectByType</span><<span class="ty">DesktopUIMode</span>>() != <span class="kw">null</span>) <span class="kw">return</span>; <span class="ann">// 이미 있으면 스킵</span>
|
||||
<span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"[DesktopUIMode]"</span>).<span class="fn">AddComponent</span><<span class="ty">DesktopUIMode</span>>();
|
||||
}
|
||||
|
||||
@@ -2073,7 +2251,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
|
||||
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="ann">// main 카메라 없으면 활성 카메라 중 첫 번째</span>
|
||||
<span class="kw">foreach</span> (<span class="kw">var</span> c <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">Camera</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||||
<span class="kw">if</span> (c.enabled && c.gameObject.scene.name != <span class="st">"DontDestroyOnLoad"</span>) { cam = c; <span class="kw">break</span>; }
|
||||
cam ??= <span class="fn">FindObjectOfType</span><<span class="ty">Camera</span>>(); <span class="ann">// 최후의 수단</span>
|
||||
cam ??= <span class="fn">FindFirstObjectByType</span><<span class="ty">Camera</span>>(); <span class="ann">// 최후의 수단</span>
|
||||
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="kw">return</span>;
|
||||
|
||||
<span class="kw">foreach</span> (<span class="kw">var</span> canvas <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">Canvas</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||||
@@ -316,6 +316,7 @@
|
||||
<a href="#naspublisher">NasPublisher.cs</a>
|
||||
<a href="#downloadmanager">DownloadManager.cs</a>
|
||||
<a href="#songcontroller">SongController.cs</a>
|
||||
<a href="#vrpointer">VR UI 포인터</a>
|
||||
|
||||
<div class="section-label">UI</div>
|
||||
<a href="#songselectmanager">SongSelectManager.cs</a>
|
||||
@@ -333,13 +334,14 @@
|
||||
<!-- 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1>VR Beat Saber — 코드 리뷰</h1>
|
||||
<p>Unity + Meta Quest 기반 커스텀 비트 세이버 클론 | 학습용 코드 리뷰 문서</p>
|
||||
<p>Unity 6000.3.12f1 + Meta Quest 기반 커스텀 비트 세이버 클론 | 최신 코드 리뷰 문서</p>
|
||||
<div style="margin-top:12px">
|
||||
<span class="badge badge-blue">Unity 2022+</span>
|
||||
<span class="badge badge-blue">Unity 6000.3.12f1</span>
|
||||
<span class="badge badge-green">C#</span>
|
||||
<span class="badge badge-purple">VRBeatsKit</span>
|
||||
<span class="badge badge-yellow">Beat Sage API</span>
|
||||
<span class="badge badge-red">Synology NAS</span>
|
||||
<span class="badge badge-green">Build: 경고 0 / 오류 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +361,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h4>Game 씬</h4>
|
||||
<p>캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.</p>
|
||||
<p>캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -416,11 +418,13 @@ Application.temporaryCachePath/beatsaber/
|
||||
<h3>스크립트 의존 관계</h3>
|
||||
<table>
|
||||
<tr><th>스크립트</th><th>의존 대상</th><th>의존 방식</th></tr>
|
||||
<tr><td>SongController</td><td>GameSession, AudioManager, VR_BeatManager</td><td>static / FindObjectOfType / singleton</td></tr>
|
||||
<tr><td>SongController</td><td>GameSession, AudioManager, VR_BeatManager</td><td>static / FindFirstObjectByType / singleton</td></tr>
|
||||
<tr><td>SongSelectManager</td><td>DownloadManager, SongDetailPanel, SongLibrary</td><td>SerializeField / singleton</td></tr>
|
||||
<tr><td>NasPublisher</td><td>BeatSageConverter</td><td>static class 직접 호출</td></tr>
|
||||
<tr><td>BeatSageUploader</td><td>BeatSageConverter, NoteData</td><td>static class 직접 호출</td></tr>
|
||||
<tr><td>DownloadManager</td><td>NoteData (SongInfo)</td><td>파라미터</td></tr>
|
||||
<tr><td>VRPointerSetup</td><td>VRPointerController, SceneManager</td><td>RuntimeInitializeOnLoadMethod / sceneLoaded</td></tr>
|
||||
<tr><td>VRPointerController</td><td>Selectable, EventSystem, XR InputDevice</td><td>직접 Ray/Rect 교차 + ExecuteEvents</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -752,6 +756,38 @@ list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<h3>오디오 싱크 — DSP 기준 예약 재생</h3>
|
||||
<div class="code-wrapper">
|
||||
<div class="code-header"><span class="code-filename">AudioManager.cs — PlayClipScheduled()</span></div>
|
||||
<pre><span class="kw">public double</span> <span class="fn">PlayClipScheduled</span>(<span class="ty">AudioClip</span> clip, <span class="kw">double</span> delaySeconds = <span class="num">0.1</span>)
|
||||
{
|
||||
audioSource.<span class="fn">Stop</span>();
|
||||
audioSource.clip = clip;
|
||||
audioSource.time = <span class="num">0.0f</span>;
|
||||
|
||||
scheduledDspStartTime = <span class="ty">AudioSettings</span>.dspTime + delaySeconds;
|
||||
hasScheduledClip = <span class="kw">true</span>;
|
||||
audioSource.<span class="fn">PlayScheduled</span>(scheduledDspStartTime);
|
||||
|
||||
<span class="kw">return</span> scheduledDspStartTime;
|
||||
}
|
||||
|
||||
<span class="kw">public float</span> CurrentTime
|
||||
{
|
||||
<span class="kw">get</span>
|
||||
{
|
||||
<span class="kw">if</span> (hasScheduledClip)
|
||||
<span class="kw">return</span> (<span class="kw">float</span>)(<span class="ty">AudioSettings</span>.dspTime - scheduledDspStartTime);
|
||||
<span class="kw">return</span> audioSource.time;
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="point point-green">
|
||||
<div class="label">개선 완료</div>
|
||||
<p><code>AudioSource.Play()</code> 대신 <code>PlayScheduled()</code>를 사용해 음악 시작 시점을 DSP 시간에 고정했다. 노트 스폰 기준도 <code>AudioSettings.dspTime</code> 차이로 계산하므로 프레임 상태에 따른 싱크 흔들림이 줄었다.</p>
|
||||
</div>
|
||||
|
||||
<div class="point point-blue">
|
||||
<div class="label">학습 포인트 — 타이밍 보정 기법</div>
|
||||
<p>
|
||||
@@ -784,9 +820,70 @@ list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version
|
||||
<h3>위치 계산</h3>
|
||||
<div class="code-wrapper">
|
||||
<div class="code-header"><span class="code-filename">SongController.cs</span></div>
|
||||
<pre><span class="cmt">// Beat Saber 그리드 → 월드 좌표 선형 매핑</span>
|
||||
<span class="ty">float</span> x = <span class="num">-0.375f</span> + note.position * <span class="num">0.25f</span>; <span class="cmt">// 열 0→-0.375, 1→-0.125, 2→0.125, 3→0.375</span>
|
||||
<span class="ty">float</span> y = <span class="num">-0.333f</span> + note.lineLayer * <span class="num">0.333f</span>; <span class="cmt">// 행 0→-0.333, 1→0, 2→0.333</span></pre>
|
||||
<pre><span class="kw">private const float</span> LaneSpacing = <span class="num">0.42f</span>;
|
||||
<span class="kw">private const float</span> LayerSpacing = <span class="num">0.38f</span>;
|
||||
<span class="kw">private const float</span> HorizontalCenter = <span class="num">1.5f</span>;
|
||||
<span class="kw">private const float</span> VerticalCenter = <span class="num">1f</span>;
|
||||
|
||||
<span class="kw">private static float</span> <span class="fn">MapLaneX</span>(<span class="ty">int</span> position)
|
||||
{
|
||||
<span class="ty">int</span> lane = Mathf.<span class="fn">Clamp</span>(position, <span class="num">0</span>, <span class="num">3</span>);
|
||||
<span class="kw">return</span> (lane - HorizontalCenter) * LaneSpacing;
|
||||
}
|
||||
|
||||
<span class="kw">private static float</span> <span class="fn">MapLayerY</span>(<span class="ty">int</span> lineLayer)
|
||||
{
|
||||
<span class="ty">int</span> layer = Mathf.<span class="fn">Clamp</span>(lineLayer, <span class="num">0</span>, <span class="num">2</span>);
|
||||
<span class="kw">return</span> (layer - VerticalCenter) * LayerSpacing;
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="point point-green">
|
||||
<div class="label">개선 완료 — 가로 겹침</div>
|
||||
<p>기존 라인 간격은 <code>0.25</code>였고 큐브 실제 폭은 약 <code>0.36</code>이라 인접 라인이 겹칠 수밖에 없었다. 현재 X 좌표는 대략 <code>-0.63, -0.21, 0.21, 0.63</code>으로 벌어져 가로 겹침을 피한다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───────────────────────────────── VR Pointer ── -->
|
||||
<section id="vrpointer">
|
||||
<h2>VRPointerController / VRPointerSetup — VR UI 클릭 안정화</h2>
|
||||
<p>게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.</p>
|
||||
|
||||
<h3>구조</h3>
|
||||
<div class="flow">
|
||||
<div class="flow-box">VRPointerSetup<br><small style="color:var(--muted)">BeforeSceneLoad 자동 생성</small></div>
|
||||
<span class="flow-arrow">→</span>
|
||||
<div class="flow-box">SceneManager.sceneLoaded</div>
|
||||
<span class="flow-arrow">→</span>
|
||||
<div class="flow-box">Controller/Hand + LineRenderer 탐색</div>
|
||||
<span class="flow-arrow">→</span>
|
||||
<div class="flow-box highlight">VRPointerController 주입</div>
|
||||
</div>
|
||||
|
||||
<div class="code-wrapper">
|
||||
<div class="code-header"><span class="code-filename">VRPointerController.cs — 클릭 처리</span></div>
|
||||
<pre><span class="kw">private static void</span> <span class="fn">Click</span>(<span class="ty">Selectable</span> sel)
|
||||
{
|
||||
<span class="kw">var</span> es = <span class="ty">EventSystem</span>.current;
|
||||
<span class="kw">var</span> eventData = <span class="kw">new</span> <span class="ty">PointerEventData</span>(es);
|
||||
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerDownHandler);
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerUpHandler);
|
||||
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerClickHandler);
|
||||
|
||||
<span class="kw">var</span> btn = sel.<span class="fn">GetComponent</span><<span class="ty">Button</span>>();
|
||||
<span class="kw">if</span> (btn != <span class="kw">null</span>) btn.onClick.<span class="fn">Invoke</span>();
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="point point-blue">
|
||||
<div class="label">평가</div>
|
||||
<p>XR UI 모듈 설정에 의존하지 않고 Selectable을 직접 Ray/Rect 교차 검사하는 방식이라 씬별 Raycaster 설정 차이에 강하다. 단, 커스텀 Selectable이나 비사각형 UI가 늘어나면 GraphicRaycaster 기반으로 재검토할 수 있다.</p>
|
||||
</div>
|
||||
|
||||
<div class="point point-yellow">
|
||||
<div class="label">실기 확인 필요</div>
|
||||
<p>Game 씬에서는 포인터가 기본 비활성이고 게임오버 시 <code>VR_InteractorController</code>를 통해 활성화된다. Quest에서 Back/Retry, SongCreator 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -875,6 +972,16 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
|
||||
<td>SongController.CutDirMap, BeatSageUploader.DiffNames</td>
|
||||
<td><p>static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deterministic Sort</td>
|
||||
<td>SongController.CompareNotes()</td>
|
||||
<td><p>time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Runtime Injection</td>
|
||||
<td>VRPointerSetup</td>
|
||||
<td><p>씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upsert</td>
|
||||
<td>NasPublisher.PatchSongsJson()</td>
|
||||
@@ -938,7 +1045,26 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
|
||||
<span class="kw">using</span> (<span class="kw">var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url)) { ... }</pre>
|
||||
</div>
|
||||
|
||||
<h3>5. Unity ?? 연산자 주의사항</h3>
|
||||
<h3>5. Unity 6 API 전환</h3>
|
||||
<div class="code-wrapper">
|
||||
<div class="code-header"><span class="code-filename">deprecated API 정리</span></div>
|
||||
<pre><span class="cmt">// 이전</span>
|
||||
FindObjectOfType<<span class="ty">AudioManager</span>>();
|
||||
FindObjectsOfType<<span class="ty">Canvas</span>>();
|
||||
tTmp.enableWordWrapping = <span class="kw">false</span>;
|
||||
|
||||
<span class="cmt">// 현재</span>
|
||||
FindFirstObjectByType<<span class="ty">AudioManager</span>>();
|
||||
FindObjectsByType<<span class="ty">Canvas</span>>(<span class="ty">FindObjectsSortMode</span>.None);
|
||||
tTmp.textWrappingMode = <span class="ty">TextWrappingModes</span>.NoWrap;</pre>
|
||||
</div>
|
||||
|
||||
<div class="point point-green">
|
||||
<div class="label">현재 상태</div>
|
||||
<p><code>dotnet build VRBeatSaber.slnx --no-incremental</code> 기준 경고 0개, 오류 0개다. VRBeatsKit 내부 deprecated API와 미사용 필드 경고까지 정리되어 있다.</p>
|
||||
</div>
|
||||
|
||||
<h3>6. Unity ?? 연산자 주의사항</h3>
|
||||
<div class="code-wrapper">
|
||||
<div class="code-header"><span class="code-filename">SaberGlow.cs 버그 사례</span></div>
|
||||
<pre><span class="cmt">// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함</span>
|
||||
@@ -1010,12 +1136,17 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
|
||||
<li class="done">SongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인</li>
|
||||
<li class="done">SongSelect 씬 — 카드 목록 + 다운로드 + 플레이</li>
|
||||
<li class="done">Game 씬 — SongController, 카운트다운, 큐브 스폰</li>
|
||||
<li class="done">travelTimeOverride — 동시 노트 보정</li>
|
||||
<li class="done">Git remote 설정 (Synology NAS)</li>
|
||||
<li class="todo">Game 씬 ScoreManager / ScoreHUD 연결</li>
|
||||
<li class="todo">Game 씬 ResultsPanel 연결 (CLEAR/FAILED, 랭크)</li>
|
||||
<li class="todo">VR 기기 실제 플레이 테스트</li>
|
||||
<li class="todo">targetTravelTime 1.8 플레이 후 미세 조정</li>
|
||||
<li class="done">travelTimeOverride — 동시 노트 도착 타이밍 보정</li>
|
||||
<li class="done">AudioManager — DSP 기준 PlayScheduled 싱크 개선</li>
|
||||
<li class="done">VRPointerController/Setup — VR UI hover/click 처리</li>
|
||||
<li class="done">GameOver Back/Retry 버튼 스크립트 참조 복구</li>
|
||||
<li class="done">큐브 가로 간격 보정 — 인접 라인 겹침 방지</li>
|
||||
<li class="done">C# 빌드 경고 0개 정리</li>
|
||||
<li class="done">Git remote 설정 및 master/main 최신화</li>
|
||||
<li class="todo">Quest 실기에서 GameOver Back/Retry 클릭 확인</li>
|
||||
<li class="todo">Quest 실기에서 SongCreator UI 클릭 확인</li>
|
||||
<li class="todo">큐브 간격, 세이버 각도, targetTravelTime 1.8 체감 조정</li>
|
||||
<li class="todo">SongCreator 생성 직후 첫 재생 싱크/로드 로그 추가 검증</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user