2026-05-26 00:18:32 +09:00
<!DOCTYPE html>
< html lang = "ko" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > VR Beat Saber — 코드 리뷰< / title >
< style >
: root {
--bg : #0d1117 ;
--surface : #161b22 ;
--surface2 : #1c2230 ;
--border : #30363d ;
--accent : #58a6ff ;
--green : #3fb950 ;
--yellow : #d29922 ;
--red : #f85149 ;
--purple : #bc8cff ;
--orange : #ffa657 ;
--text : #e6edf3 ;
--muted : #8b949e ;
}
* { box-sizing : border-box ; margin : 0 ; padding : 0 ; }
body {
background : var ( - - bg ) ;
color : var ( - - text ) ;
font-family : 'Segoe UI' , system-ui , sans-serif ;
font-size : 14 px ;
line-height : 1.6 ;
}
/* ── 사이드바 ── */
# sidebar {
position : fixed ; top : 0 ; left : 0 ;
width : 240 px ; height : 100 vh ;
background : var ( - - surface ) ;
border-right : 1 px solid var ( - - border ) ;
overflow-y : auto ;
padding : 20 px 0 ;
z-index : 100 ;
}
# sidebar h2 {
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : .08 em ;
color : var ( - - muted ) ;
padding : 0 16 px 8 px ;
border-bottom : 1 px solid var ( - - border ) ;
margin-bottom : 8 px ;
}
# sidebar a {
display : block ;
padding : 5 px 16 px ;
color : var ( - - muted ) ;
text-decoration : none ;
font-size : 13 px ;
border-left : 2 px solid transparent ;
transition : all .15 s ;
}
# sidebar a : hover , # sidebar a . active {
color : var ( - - accent ) ;
border-left-color : var ( - - accent ) ;
background : rgba ( 88 , 166 , 255 , .07 ) ;
}
# sidebar . section-label {
padding : 12 px 16 px 4 px ;
font-size : 10 px ;
text-transform : uppercase ;
letter-spacing : .1 em ;
color : var ( - - border ) ;
}
/* ── 메인 ── */
# main {
margin-left : 240 px ;
padding : 40 px 48 px ;
max-width : 1100 px ;
}
/* ── 헤더 ── */
. page-header {
margin-bottom : 40 px ;
padding-bottom : 24 px ;
border-bottom : 1 px solid var ( - - border ) ;
}
. page-header h1 { font-size : 28 px ; font-weight : 700 ; }
. page-header p { color : var ( - - muted ) ; margin-top : 6 px ; }
. badge {
display : inline-block ;
padding : 2 px 8 px ;
border-radius : 12 px ;
font-size : 11 px ;
font-weight : 600 ;
margin-right : 6 px ;
}
. badge-blue { background : rgba ( 88 , 166 , 255 , .15 ) ; color : var ( - - accent ) ; }
. badge-green { background : rgba ( 63 , 185 , 80 , .15 ) ; color : var ( - - green ) ; }
. badge-yellow { background : rgba ( 210 , 153 , 34 , .15 ) ; color : var ( - - yellow ) ; }
. badge-red { background : rgba ( 248 , 81 , 73 , .15 ) ; color : var ( - - red ) ; }
. badge-purple { background : rgba ( 188 , 140 , 255 , .15 ) ; color : var ( - - purple ) ; }
/* ── 섹션 ── */
section { margin-bottom : 60 px ; scroll-margin-top : 20 px ; }
section > h2 {
font-size : 20 px ;
font-weight : 700 ;
margin-bottom : 16 px ;
padding-bottom : 10 px ;
border-bottom : 1 px solid var ( - - border ) ;
color : var ( - - text ) ;
}
section > h3 {
font-size : 15 px ;
font-weight : 600 ;
margin : 28 px 0 10 px ;
color : var ( - - accent ) ;
}
p { color : var ( - - muted ) ; margin-bottom : 10 px ; line-height : 1.7 ; }
strong { color : var ( - - text ) ; }
/* ── 카드 ── */
. card {
background : var ( - - surface ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
padding : 20 px 24 px ;
margin-bottom : 16 px ;
}
. card h4 {
font-size : 13 px ;
font-weight : 700 ;
color : var ( - - text ) ;
margin-bottom : 8 px ;
}
. card p { margin : 0 ; font-size : 13 px ; }
/* ── 플로우 다이어그램 ── */
. flow {
display : flex ;
align-items : center ;
flex-wrap : wrap ;
gap : 8 px ;
margin : 16 px 0 ;
}
. flow-box {
background : var ( - - surface2 ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 6 px ;
padding : 8 px 14 px ;
font-size : 12 px ;
font-weight : 600 ;
color : var ( - - text ) ;
white-space : nowrap ;
}
. flow-box . highlight { border-color : var ( - - accent ) ; color : var ( - - accent ) ; }
. flow-arrow { color : var ( - - muted ) ; font-size : 16 px ; }
/* ── 코드 블록 ── */
. code-wrapper {
background : #0a0e14 ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
overflow : hidden ;
margin : 12 px 0 20 px ;
}
. code-header {
display : flex ;
justify-content : space-between ;
align-items : center ;
padding : 8 px 16 px ;
background : var ( - - surface ) ;
border-bottom : 1 px solid var ( - - border ) ;
font-size : 11 px ;
color : var ( - - muted ) ;
}
. code-filename { font-weight : 600 ; color : var ( - - text ) ; }
pre {
padding : 16 px 20 px ;
overflow-x : auto ;
font-family : 'Cascadia Code' , 'Fira Code' , Consolas , monospace ;
font-size : 12.5 px ;
line-height : 1.65 ;
}
/* 하이라이트 토큰 */
. kw { color : #ff7b72 ; } /* keyword */
. ty { color : #ffa657 ; } /* type */
. fn { color : #d2a8ff ; } /* function */
. str { color : #a5d6ff ; } /* string */
. num { color : #79c0ff ; } /* number */
. cmt { color : #8b949e ; font-style : italic ; } /* comment */
. var { color : #e6edf3 ; } /* variable */
. op { color : #ff7b72 ; } /* operator */
/* ── 포인트 박스 ── */
. point {
border-left : 3 px solid ;
padding : 10 px 16 px ;
border-radius : 0 6 px 6 px 0 ;
margin : 10 px 0 ;
font-size : 13 px ;
}
. point-blue { border-color : var ( - - accent ) ; background : rgba ( 88 , 166 , 255 , .06 ) ; }
. point-green { border-color : var ( - - green ) ; background : rgba ( 63 , 185 , 80 , .06 ) ; }
. point-yellow { border-color : var ( - - yellow ) ; background : rgba ( 210 , 153 , 34 , .06 ) ; }
. point-red { border-color : var ( - - red ) ; background : rgba ( 248 , 81 , 73 , .06 ) ; }
. point p { margin : 0 ; color : var ( - - text ) ; }
. point . label {
font-size : 10 px ;
font-weight : 700 ;
text-transform : uppercase ;
letter-spacing : .08 em ;
margin-bottom : 4 px ;
}
. point-blue . label { color : var ( - - accent ) ; }
. point-green . label { color : var ( - - green ) ; }
. point-yellow . label { color : var ( - - yellow ) ; }
. point-red . label { color : var ( - - red ) ; }
/* ── 테이블 ── */
table {
width : 100 % ;
border-collapse : collapse ;
margin : 12 px 0 20 px ;
font-size : 13 px ;
}
th {
background : var ( - - surface ) ;
color : var ( - - muted ) ;
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : .05 em ;
padding : 8 px 14 px ;
text-align : left ;
border-bottom : 1 px solid var ( - - border ) ;
}
td {
padding : 9 px 14 px ;
border-bottom : 1 px solid rgba ( 48 , 54 , 61 , .5 ) ;
color : var ( - - text ) ;
vertical-align : top ;
}
tr : last-child td { border-bottom : none ; }
td : first-child { font-family : Consolas , monospace ; color : var ( - - orange ) ; }
td p { margin : 0 ; font-size : 13 px ; }
/* ── 체크리스트 ── */
. checklist { list-style : none ; padding : 0 ; }
. checklist li {
padding : 6 px 0 ;
padding-left : 24 px ;
position : relative ;
font-size : 13 px ;
color : var ( - - muted ) ;
}
. checklist li :: before {
content : '□' ;
position : absolute ;
left : 0 ;
color : var ( - - border ) ;
}
. checklist li . done { color : var ( - - green ) ; }
. checklist li . done :: before { content : '✓' ; color : var ( - - green ) ; }
. checklist li . todo :: before { content : '○' ; color : var ( - - yellow ) ; }
/* ── Quiz ── */
. quiz {
background : var ( - - surface ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
padding : 16 px 20 px ;
margin : 12 px 0 ;
}
. quiz . q { font-size : 13 px ; font-weight : 600 ; color : var ( - - text ) ; margin-bottom : 10 px ; }
. quiz . answer {
display : none ;
background : #0a0e14 ;
border-radius : 6 px ;
padding : 10 px 14 px ;
margin-top : 10 px ;
font-size : 13 px ;
color : var ( - - muted ) ;
border-left : 3 px solid var ( - - green ) ;
}
. quiz button {
background : var ( - - surface2 ) ;
border : 1 px solid var ( - - border ) ;
color : var ( - - muted ) ;
padding : 4 px 12 px ;
border-radius : 4 px ;
cursor : pointer ;
font-size : 12 px ;
transition : all .15 s ;
}
. quiz button : hover { border-color : var ( - - accent ) ; color : var ( - - accent ) ; }
/* ── 스크롤바 ── */
:: -webkit-scrollbar { width : 6 px ; height : 6 px ; }
:: -webkit-scrollbar-track { background : transparent ; }
:: -webkit-scrollbar-thumb { background : var ( - - border ) ; border-radius : 3 px ; }
< / style >
< / head >
< body >
<!-- 사이드바 -->
< nav id = "sidebar" >
< h2 > VR Beat Saber< / h2 >
< a href = "#overview" > 프로젝트 개요< / a >
< a href = "#architecture" > 아키텍처 & 데이터 흐름< / a >
< div class = "section-label" > 데이터 모델< / div >
< a href = "#notedata" > NoteData.cs< / a >
< a href = "#gamesession" > GameSession.cs< / a >
< div class = "section-label" > 핵심 로직< / div >
< a href = "#beatconverter" > BeatSageConverter.cs< / a >
< a href = "#beatsageuploader" > BeatSageUploader.cs< / a >
< a href = "#naspublisher" > NasPublisher.cs< / a >
< a href = "#downloadmanager" > DownloadManager.cs< / a >
< a href = "#songcontroller" > SongController.cs< / a >
2026-05-26 19:12:06 +09:00
< a href = "#vrpointer" > VR UI 포인터< / a >
2026-05-26 00:18:32 +09:00
< div class = "section-label" > UI< / div >
< a href = "#songselectmanager" > SongSelectManager.cs< / a >
< div class = "section-label" > 학습 정리< / div >
< a href = "#patterns" > 디자인 패턴< / a >
< a href = "#unity-tips" > Unity 핵심 팁< / a >
< a href = "#quiz" > 셀프 퀴즈< / a >
< a href = "#todo" > 남은 작업< / a >
< / nav >
<!-- 메인 -->
< div id = "main" >
<!-- 헤더 -->
< div class = "page-header" >
< h1 > VR Beat Saber — 코드 리뷰< / h1 >
2026-05-26 19:12:06 +09:00
< p > Unity 6000.3.12f1 + Meta Quest 기반 커스텀 비트 세이버 클론 | 최신 코드 리뷰 문서< / p >
2026-05-26 00:18:32 +09:00
< div style = "margin-top:12px" >
2026-05-26 19:12:06 +09:00
< span class = "badge badge-blue" > Unity 6000.3.12f1< / span >
2026-05-26 00:18:32 +09:00
< 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 >
2026-05-26 19:12:06 +09:00
< span class = "badge badge-green" > Build: 경고 0 / 오류 0< / span >
2026-05-26 00:18:32 +09:00
< / div >
< / div >
<!-- ───────────────────────────────── 개요 ── -->
< section id = "overview" >
< h2 > 프로젝트 개요< / h2 >
< p > 유저가 MP3 파일이나 URL을 올리면 Beat Sage AI가 비트맵을 자동 생성하고, Synology NAS에 저장한 뒤 Quest VR에서 플레이하는 시스템이다.< / p >
< div style = "display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:16px" >
< div class = "card" >
< h4 > SongCreator 씬< / h4 >
< p > MP3/URL 입력 → Beat Sage API → NAS 업로드. 곡을 시스템에 등록하는 파이프라인.< / p >
< / div >
< div class = "card" >
< h4 > SongSelect 씬< / h4 >
< p > NAS에서 songs.json 로드 → 카드 목록 표시 → 다운로드/플레이 선택.< / p >
< / div >
< div class = "card" >
< h4 > Game 씬< / h4 >
2026-05-26 19:12:06 +09:00
< p > 캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.< / p >
2026-05-26 00:18:32 +09:00
< / div >
< / div >
< / section >
<!-- ───────────────────────────────── 아키텍처 ── -->
< section id = "architecture" >
< h2 > 아키텍처 & 데이터 흐름< / h2 >
< h3 > 전체 파이프라인< / h3 >
< div class = "flow" >
< div class = "flow-box highlight" > SongCreator< / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > BeatSageUploader< br > < small style = "color:var(--muted)" > AI 비트맵 생성< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > BeatSageConverter< br > < small style = "color:var(--muted)" > .dat → NoteData< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > NasPublisher< br > < small style = "color:var(--muted)" > NAS 업로드< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > songs.json 갱신< / div >
< / div >
< div class = "flow" style = "margin-top:4px" >
< div class = "flow-box highlight" > SongSelect< / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > DownloadManager< br > < small style = "color:var(--muted)" > NAS → 로컬 캐시< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > GameSession< br > < small style = "color:var(--muted)" > 씬 간 선택 전달< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box highlight" > Game 씬< / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > SongController< br > < small style = "color:var(--muted)" > 로드 + 스폰< / small > < / div >
< / div >
< h3 > 캐시 경로 규칙< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" >
< span class = "code-filename" > Cache Layout< / span >
< span > Application.temporaryCachePath< / span >
< / div >
< pre > < span class = "cmt" > // DownloadManager, SongController 공통 경로 — 항상 일치해야 함< / span >
Application.temporaryCachePath/beatsaber/
{songId}/
{songId}.mp3 < span class = "cmt" > ← 오디오< / span >
Map_{songId}_normal.json < span class = "cmt" > ← 난이도별 맵 JSON< / span >
Map_{songId}_hard.json
Map_{songId}_expert.json
Map_{songId}_expertplus.json< / pre >
< / div >
< div class = "point point-yellow" >
< div class = "label" > 주의< / div >
< p > < code > SongController< / code > 와 < code > DownloadManager< / code > 가 각자 < code > CacheRoot< / code > 프로퍼티를 중복 선언한다. 경로가 달라지면 파일을 못 찾으므로 공용 상수로 리팩토링하면 좋다.< / p >
< / div >
< h3 > 스크립트 의존 관계< / h3 >
< table >
< tr > < th > 스크립트< / th > < th > 의존 대상< / th > < th > 의존 방식< / th > < / tr >
2026-05-26 19:12:06 +09:00
< tr > < td > SongController< / td > < td > GameSession, AudioManager, VR_BeatManager< / td > < td > static / FindFirstObjectByType / singleton< / td > < / tr >
2026-05-26 00:18:32 +09:00
< 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 >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< / table >
< / section >
<!-- ───────────────────────────────── NoteData ── -->
< section id = "notedata" >
< h2 > NoteData.cs — 데이터 모델 계층< / h2 >
< p > 프로젝트의 모든 데이터 구조가 한 파일에 정의되어 있다. Unity의 < code > JsonUtility< / code > 와 호환되도록 < strong > [Serializable]< / strong > 속성이 붙어 있다.< / p >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > NoteData.cs< / span > < / div >
< pre > < span class = "kw" > public class< / span > < span class = "ty" > NoteData< / span >
{
< span class = "kw" > public< / span > < span class = "ty" > float< / span > time; < span class = "cmt" > // 초 단위 (비트 → 초 변환 후)< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > position; < span class = "cmt" > // 열 0-3< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > lineLayer; < span class = "cmt" > // 행 0-2< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > colorType; < span class = "cmt" > // 0=빨강, 1=파랑< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > cutDirection; < span class = "cmt" > // 0-8 (Beat Saber 스펙)< / span >
}
< span class = "kw" > public class< / span > < span class = "ty" > DifficultyMap< / span >
{
< span class = "kw" > public< / span > < span class = "ty" > DifficultyInfo< / span > normal;
< span class = "kw" > public< / span > < span class = "ty" > DifficultyInfo< / span > hard;
< span class = "kw" > public< / span > < span class = "ty" > DifficultyInfo< / span > expert;
< span class = "kw" > public< / span > < span class = "ty" > DifficultyInfo< / span > expertplus;
< span class = "kw" > public< / span > < span class = "ty" > DifficultyInfo< / span > < span class = "fn" > Get< / span > (< span class = "ty" > string< / span > key) < span class = "op" > =>< / span > key < span class = "kw" > switch< / span > < span class = "cmt" > // C# 8 switch expression< / span >
{
< span class = "str" > "normal"< / span > < span class = "op" > =>< / span > normal,
< span class = "str" > "hard"< / span > < span class = "op" > =>< / span > hard,
< span class = "str" > "expert"< / span > < span class = "op" > =>< / span > expert,
< span class = "str" > "expertplus"< / span > < span class = "op" > =>< / span > expertplus,
_ < span class = "op" > =>< / span > < span class = "kw" > null< / span >
};
}< / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트< / div >
< p > < strong > C# 8 switch expression< / strong > : 전통적인 < code > switch< / code > 문 대신 람다처럼 쓸 수 있는 표현식. < code > _< / code > 는 default 케이스다. 반환값이 있는 간단한 매핑에 적합.< / p >
< / div >
< div class = "point point-yellow" >
< div class = "label" > 설계 트레이드오프< / div >
< p > < code > DifficultyMap< / code > 은 필드 4개가 하드코딩되어 있다. 난이도가 추가되면 클래스를 수정해야 한다. < code > Dictionary< string, DifficultyInfo> < / code > 로 바꾸면 유연하지만 < code > JsonUtility< / code > 가 Dictionary를 직렬화하지 못하므로 < code > Newtonsoft.Json< / code > 이 필요하다.< / p >
< / div >
< / section >
<!-- ───────────────────────────────── GameSession ── -->
< section id = "gamesession" >
< h2 > GameSession.cs — 씬 간 데이터 전달< / h2 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > GameSession.cs< / span > < / div >
< pre > < span class = "cmt" > // static container — 씬이 바뀌어도 메모리에 남는다< / span >
< span class = "kw" > public static class< / span > < span class = "ty" > GameSession< / span >
{
< span class = "kw" > public static< / span > < span class = "ty" > SongInfo< / span > SelectedSong;
< span class = "kw" > public static< / span > < span class = "ty" > string< / span > SelectedDifficulty;
}< / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — static class란?< / div >
< p > 인스턴스를 생성할 수 없고, 모든 멤버가 자동으로 < code > static< / code > 이다. < code > MonoBehaviour< / code > 를 상속하지 않으므로 씬이 바뀌어도 값이 유지된다. Unity에서 씬 간 데이터를 넘기는 가장 단순한 방법.< / p >
< / div >
< div class = "point point-yellow" >
< div class = "label" > 대안 비교< / div >
< p >
< strong > DontDestroyOnLoad< / strong > : MonoBehaviour 기반, 씬 전환 후에도 GameObject 유지.< br >
< strong > ScriptableObject< / strong > : Inspector에서 확인 가능, 에디터 재시작 전까지 유지.< br >
< strong > PlayerPrefs< / strong > : 앱 재시작 후에도 유지 (영구 저장).< br >
< strong > static class< / strong > : 가장 단순. 앱 종료하면 사라짐. 이 프로젝트엔 충분.
< / p >
< / div >
< / section >
<!-- ───────────────────────────────── BeatSageConverter ── -->
< section id = "beatconverter" >
< h2 > BeatSageConverter.cs — 포맷 변환< / h2 >
< p > Beat Saber의 < code > .dat< / code > JSON 포맷을 우리 내부 < code > NoteData< / code > 로 변환하는 순수 로직 클래스다.< / p >
< h3 > 핵심 변환: 비트 → 초< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageConverter.cs — Convert()< / span > < / div >
< pre > < span class = "cmt" > // Beat Saber의 _time은 "비트 단위"< / span >
< span class = "cmt" > // 실제 시간(초) = 비트 / BPM * 60< / span >
time = (note._time * < span class = "num" > 60f< / span > ) / bpm,
< span class = "cmt" > // 예: BPM=120, _time=4 → 4/120*60 = 2.0초< / span >
< span class = "cmt" > // 폭탄(type=3), 장애물 등은 스킵< / span >
< span class = "kw" > if< / span > (note._type != < span class = "num" > 0< / span > & & note._type != < span class = "num" > 1< / span > ) < span class = "kw" > continue< / span > ;< / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — static class 사용 이유< / div >
< p > 상태(필드)가 전혀 없는 순수 함수들의 모음이다. 인스턴스를 만들 필요가 없으므로 < code > static class< / code > 가 적합. C#의 < strong > 유틸리티 클래스 패턴< / strong > .< / p >
< / div >
< h3 > BPM 자동 감지 (info.dat)< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageConverter.cs — ParseInfoDat()< / span > < / div >
< pre > < span class = "kw" > public static< / span > < span class = "ty" > SongMetadata< / span > < span class = "fn" > ParseInfoDat< / span > (< span class = "ty" > string< / span > json)
{
< span class = "kw" > var< / span > info = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > BeatSageInfoDat< / span > > (json);
< span class = "kw" > if< / span > (info == < span class = "kw" > null< / span > ) < span class = "kw" > return null< / span > ;
< span class = "kw" > return new< / span > < span class = "ty" > SongMetadata< / span >
{
title = (info._songName ?? < span class = "str" > ""< / span > ).< span class = "fn" > Trim< / span > (), < span class = "cmt" > // null 병합 연산자< / span >
artist = (info._songAuthorName ?? < span class = "str" > ""< / span > ).< span class = "fn" > Trim< / span > (),
bpm = info._beatsPerMinute,
};
}< / pre >
< / div >
< div class = "point point-green" >
< div class = "label" > null 병합 연산자 ??< / div >
< p > < code > a ?? b< / code > — a가 null이면 b를 반환. < code > a != null ? a : b< / code > 의 축약형. JSON 파싱 시 누락된 필드가 null로 들어올 때 안전하게 처리.< / p >
< / div >
< / section >
<!-- ───────────────────────────────── BeatSageUploader ── -->
< section id = "beatsageuploader" >
< h2 > BeatSageUploader.cs — 외부 API 연동< / h2 >
< p > Beat Sage 서버에 오디오를 올리고, 생성 완료를 폴링한 뒤, 결과 ZIP을 다운받아 변환까지 처리한다.< / p >
< h3 > 코루틴 체이닝 구조< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageUploader.cs — Upload() 흐름< / span > < / div >
< pre > Upload() / UploadFromUrl()
└─ < span class = "fn" > CreateLevel< / span > () / < span class = "fn" > CreateLevelFromUrl< / span > () < span class = "cmt" > // [1/4] POST → levelId 획득< / span >
└─ < span class = "fn" > PollAndDownload< / span > () < span class = "cmt" > // 공통 phase 2~4< / span >
├─ < span class = "fn" > PollHeartbeat< / span > () (5초 간격, 최대 300초) < span class = "cmt" > // [2/4] 생성 완료 대기< / span >
├─ < span class = "fn" > DownloadZip< / span > () (최대 3회 재시도) < span class = "cmt" > // [3/4] ZIP 다운로드< / span >
└─ < span class = "fn" > ExtractAndConvert< / span > () < span class = "cmt" > // [4/4] .dat → NoteData< / span > < / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — yield return StartCoroutine()< / div >
< p > 코루틴 안에서 다른 코루틴을 < code > yield return StartCoroutine()< / code > 으로 호출하면 내부 코루틴이 끝날 때까지 기다린다. 순차적인 비동기 작업을 async/await 없이 체이닝하는 Unity 방식.< / p >
< / div >
< h3 > 멀티파트 폼 — URL vs 파일< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageUploader.cs< / span > < / div >
< pre > < span class = "cmt" > // URL 업로드: audio_url 필드에 문자열< / span >
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "str" > "audio_url"< / span > , audioUrl)
< span class = "cmt" > // 파일 업로드: 바이트 배열 + MIME 타입< / span >
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormFileSection< / span > (< span class = "str" > "audio_file"< / span > , audioBytes, fileName, < span class = "str" > "audio/mpeg"< / span > )< / pre >
< / div >
< h3 > 폴링 타임아웃 패턴< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageUploader.cs — PollAndDownload()< / span > < / div >
< pre > < span class = "kw" > float< / span > elapsed = < span class = "num" > 0f< / span > ;
< span class = "kw" > while< / span > (!ready & & elapsed < POLL_TIMEOUT ) < span class = "cmt" > // 300초 초과 시 탈출< / span >
{
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (POLL_INTERVAL); < span class = "cmt" > // 5초 대기< / span >
elapsed += POLL_INTERVAL;
< span class = "kw" > yield return< / span > < span class = "fn" > PollHeartbeat< / span > (levelId, status => {
ready = status == < span class = "str" > "generated"< / span > || status == < span class = "str" > "done"< / span > ;
error = status == < span class = "str" > "error"< / span > ;
}, onError);
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "num" > 0.15f< / span > + Mathf.< span class = "fn" > Clamp01< / span > (elapsed / POLL_TIMEOUT) * < span class = "num" > 0.6f< / span > );
}< / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — Action 콜백 패턴< / div >
< p > Unity 코루틴은 값을 < code > return< / code > 할 수 없다. 대신 < strong > 콜백(Action)< / strong > 을 파라미터로 받아서 결과를 전달한다. < code > onSuccess< / code > , < code > onError< / code > 가 대표적. < code > ?.< / code > 는 null 조건부 호출 — null이면 아무것도 안 함.< / p >
< / div >
< h3 > 간이 JSON 파싱< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > BeatSageUploader.cs — ParseJsonString()< / span > < / div >
< pre > < span class = "kw" > private static string< / span > < span class = "fn" > ParseJsonString< / span > (< span class = "ty" > string< / span > json, < span class = "ty" > string< / span > key)
{
< span class = "ty" > string< / span > search = $< span class = "str" > "\"{key}\":"< / span > ; < span class = "cmt" > // "id":" 를 찾음< / span >
< span class = "kw" > int< / span > start = json.< span class = "fn" > IndexOf< / span > (search);
< span class = "kw" > if< / span > (start < < span class = "num" > 0< / span > ) < span class = "kw" > return null< / span > ;
start += search.Length;
< span class = "kw" > int< / span > end = json.< span class = "fn" > IndexOf< / span > (< span class = "str" > '"'< / span > , start); < span class = "cmt" > // 닫는 " 위치< / span >
< span class = "kw" > return< / span > end > start ? json.< span class = "fn" > Substring< / span > (start, end - start) : < span class = "kw" > null< / span > ;
}< / pre >
< / div >
< div class = "point point-yellow" >
< div class = "label" > 주의 — 수동 파싱의 한계< / div >
< p > Beat Sage 응답이 간단해서 < code > IndexOf< / code > 로 파싱하지만, JSON 값에 이스케이프된 따옴표(< code > \"< / code > )가 있으면 틀린다. 복잡한 응답엔 < code > JsonUtility< / code > 나 < code > Newtonsoft.Json< / code > 을 써야 한다.< / p >
< / div >
< / section >
<!-- ───────────────────────────────── NasPublisher ── -->
< section id = "naspublisher" >
< h2 > NasPublisher.cs — Synology DSM API< / h2 >
< p > DSM FileStation API를 통해 NAS에 파일을 업로드한다. < strong > 로그인 → 업로드 → 로그아웃< / strong > 세션 흐름을 코루틴 체이닝으로 구현.< / p >
< h3 > 수동 multipart body 구성 — 핵심 패턴< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > NasPublisher.cs — UploadBytes()< / span > < / div >
< pre > < span class = "cmt" > // Unity 기본 UnityWebRequest.Post(form) 는 DSM에서 401 반환 → 수동 구성 필요< / span >
< span class = "ty" > string< / span > boundary = Guid.< span class = "fn" > NewGuid< / span > ().< span class = "fn" > ToString< / span > (< span class = "str" > "N"< / span > ); < span class = "cmt" > // 랜덤 경계 문자열< / span >
< span class = "kw" > using var< / span > body = < span class = "kw" > new< / span > < span class = "ty" > MemoryStream< / span > ();
< span class = "cmt" > // 텍스트 필드< / span >
WriteField(< span class = "str" > "path"< / span > , nasFolder);
WriteField(< span class = "str" > "create_parents"< / span > , < span class = "str" > "true"< / span > );
WriteField(< span class = "str" > "overwrite"< / span > , < span class = "str" > "true"< / span > );
< span class = "cmt" > // 파일 파트< / span >
WriteText($< span class = "str" > "--{boundary}\r\n"< / span > );
WriteText($< span class = "str" > "Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"\r\n"< / span > );
WriteText(< span class = "str" > "Content-Type: application/octet-stream\r\n\r\n"< / span > );
body.< span class = "fn" > Write< / span > (bytes, < span class = "num" > 0< / span > , bytes.Length);
WriteText(< span class = "str" > "\r\n--{boundary}--\r\n"< / span > ); < span class = "cmt" > // 종료 경계< / span >
req.uploadHandler = < span class = "kw" > new< / span > < span class = "ty" > UploadHandlerRaw< / span > (body.< span class = "fn" > ToArray< / span > ());
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "str" > "Content-Type"< / span > , $< span class = "str" > "multipart/form-data; boundary={boundary}"< / span > );< / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — multipart/form-data 구조< / div >
< p >
< code > --boundary< / code > 로 각 파트를 구분하고, 마지막은 < code > --boundary--< / code > 로 끝낸다.< br >
< code > Content-Disposition< / code > 에 < code > name< / code > (필드명)과 파일이면 < code > filename< / code > 을 명시.< br >
바이너리 데이터는 < code > MemoryStream< / code > 에 직접 < code > Write()< / code > .
< / p >
< / div >
< h3 > songs.json Patch 전략< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > NasPublisher.cs — PatchSongsJson()< / span > < / div >
< pre > < span class = "cmt" > // 기존 목록 읽기 → 없으면 새로 생성 (null 병합)< / span >
list ??= < span class = "kw" > new< / span > < span class = "ty" > SongsList< / span > { version = < span class = "str" > "1.0"< / span > , songs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > SongInfo< / span > > () };
< span class = "cmt" > // 같은 id가 있으면 교체, 없으면 추가 — upsert 패턴< / span >
< span class = "kw" > int< / span > idx = list.songs.< span class = "fn" > FindIndex< / span > (s => s.id == newSong.id);
< span class = "kw" > if< / span > (idx >= < span class = "num" > 0< / span > ) list.songs[idx] = newSong;
< span class = "kw" > else< / span > list.songs.< span class = "fn" > Add< / span > (newSong);< / pre >
< / div >
< div class = "point point-green" >
< div class = "label" > 학습 포인트 — Upsert 패턴< / div >
< p > DB의 INSERT OR UPDATE와 동일 개념. 리스트에서 인덱스를 찾아 있으면 교체, 없으면 추가. < code > FindIndex< / code > 는 조건 람다를 받아 인덱스를 반환하며, 없으면 < code > -1< / code > .< / p >
< / div >
< / section >
<!-- ───────────────────────────────── DownloadManager ── -->
< section id = "downloadmanager" >
< h2 > DownloadManager.cs — 파일 다운로드 캐시< / h2 >
< p > NAS 정적 서버에서 MP3와 맵 JSON을 로컬 캐시로 내려받는다. < strong > 오디오 70% + 맵 30%< / strong > 비율로 진행률을 계산.< / p >
< h3 > 진행률 분할 계산< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > DownloadManager.cs — DownloadSongCoroutine()< / span > < / div >
< pre > < span class = "cmt" > // 1단계: 오디오 (전체의 0% ~ 70%)< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadFile< / span > (audioUrl, audioPath,
p => onProgress?.< span class = "fn" > Invoke< / span > (p * < span class = "num" > 0.7f< / span > ), < span class = "cmt" > // 0.0 ~ 0.7< / span >
err => { ... });
< span class = "cmt" > // 2단계: 맵 (전체의 70% ~ 100%)< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadFile< / span > (mapUrl, mapPath,
p => onProgress?.< span class = "fn" > Invoke< / span > (< span class = "num" > 0.7f< / span > + p * < span class = "num" > 0.3f< / span > ), < span class = "cmt" > // 0.7 ~ 1.0< / span >
err => { ... });< / pre >
< / div >
< h3 > 이미 있으면 스킵< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > DownloadManager.cs< / span > < / div >
< pre > < span class = "cmt" > // 캐시 히트 → 다운로드 건너뜀 (멱등성 보장)< / span >
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (audioPath))
{
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadFile< / span > (...);
}< / pre >
< / div >
< div class = "point point-green" >
< div class = "label" > 학습 포인트 — 멱등성(Idempotency)< / div >
< p > 같은 작업을 여러 번 해도 결과가 동일한 성질. 이미 다운로드된 파일은 재다운로드하지 않으므로 < strong > 다운로드 중 앱 종료 후 재시도해도 안전< / strong > 하다. 단, 오디오는 있고 맵이 없으면 오디오를 스킵하고 맵만 받는다.< / p >
< / div >
< div class = "point point-yellow" >
< div class = "label" > 주의 — 실패 시 불완전 파일< / div >
< p > < code > DownloadHandlerFile< / code > 이 실패하면 < code > File.Delete(savePath)< / code > 로 정리한다. 그렇지 않으면 0바이트 파일이 남아 다음에 "캐시 히트"로 오판할 수 있다.< / p >
< / div >
< / section >
<!-- ───────────────────────────────── SongController ── -->
< section id = "songcontroller" >
< h2 > SongController.cs — Game 씬 핵심 브릿지< / h2 >
< p > Game 씬이 시작되면 GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고, 카운트다운 후 큐브를 스폰한다.< / p >
< h3 > 전체 코루틴 흐름< / h3 >
< div class = "flow" >
< div class = "flow-box" > Start()< / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > LoadAndPlay()< / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > GetAudioClip< br > < small style = "color:var(--muted)" > 비동기< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > File.ReadAllText< br > < small style = "color:var(--muted)" > 동기< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box" > Countdown< br > < small style = "color:var(--muted)" > 3,2,1,GO< / small > < / div >
< span class = "flow-arrow" > →< / span >
< div class = "flow-box highlight" > SpawnRoutine< / div >
< span class = "flow-arrow" > +< / span >
< div class = "flow-box highlight" > WaitForCompletion< / div >
< / div >
< h3 > travelTimeOverride — 동시 노트 보정< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongController.cs — SpawnNote()< / span > < / div >
< pre > < span class = "kw" > private void< / span > < span class = "fn" > SpawnNote< / span > (< span class = "ty" > NoteData< / span > note)
{
< span class = "cmt" > // 스폰 시점의 실제 남은 시간으로 travelTime을 역산< / span >
< span class = "cmt" > // → foreach 순차 처리로 생기는 16ms 프레임 차이를 흡수< / span >
< span class = "ty" > float< / span > remaining = note.time - _audio.CurrentTime;
< span class = "ty" > float< / span > travelTime = Mathf.< span class = "fn" > Max< / span > (< span class = "num" > 0.05f< / span > , remaining);
< span class = "kw" > var< / span > info = < span class = "kw" > new< / span > < span class = "ty" > SpawnEventInfo< / span >
{
travelTimeOverride = travelTime, < span class = "cmt" > // VRBeatsKit이 이 값을 우선 사용< / span >
...
};
}< / pre >
< / div >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — 타이밍 보정 기법< / div >
< p >
< strong > 문제:< / strong > BPM=120에서 동시 노트 2개가 foreach로 처리되면 1프레임(16ms) 차이가 난다.< br >
< strong > 해결:< / strong > travelTime을 "설정값"이 아니라 "스폰 시점에서 목표 시간까지 남은 실제 시간"으로 주입.< br >
노트 A를 스폰할 때 remaining=1.800, B를 스폰할 때 remaining=1.784 → 각각 다른 speed로 발사되어 < strong > 히트존에 동시 도착< / strong > .
< / p >
< / div >
< h3 > cutDirection 매핑 — 조회 테이블< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongController.cs< / span > < / div >
< pre > < span class = "cmt" > // if-else 체인 대신 배열 인덱스로 O(1) 매핑< / span >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Direction< / span > [] CutDirMap =
{
Direction.Up, Direction.Down, Direction.Left, Direction.Right,
Direction.UpperLeft, Direction.UpperRight, Direction.LowerLeft, Direction.LowerRight,
Direction.Center, < span class = "cmt" > // index 8 = Any/Dot< / span >
};
< span class = "kw" > private static< / span > < span class = "ty" > Direction< / span > < span class = "fn" > MapCutDirection< / span > (< span class = "ty" > int< / span > cut)
< span class = "op" > =>< / span > (cut >= < span class = "num" > 0< / span > & & cut < CutDirMap.Length ) ? CutDirMap [ cut ] : Direction . Center ; < / pre >
< / div >
< div class = "point point-green" >
< div class = "label" > 학습 포인트 — 조회 테이블(Lookup Table)< / div >
< p > switch/if-else 9개 대신 < code > static readonly< / code > 배열 + 인덱스 접근. 컴파일 타임에 메모리를 할당하고 GC 없이 재사용. 매핑 항목이 많을수록 코드가 깔끔하고 빠르다.< / p >
< / div >
< h3 > 위치 계산< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongController.cs< / span > < / div >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< / div >
< / section >
<!-- ───────────────────────────────── SongSelectManager ── -->
< section id = "songselectmanager" >
< h2 > SongSelectManager.cs — 동적 UI 생성< / h2 >
< p > Inspector에서 프리팹을 쓰지 않고 < strong > 코드로 직접 GameObject를 생성< / strong > 해 카드 리스트를 만든다.< / p >
< h3 > 카드 생성 패턴< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongSelectManager.cs — SpawnCard()< / span > < / div >
< pre > < span class = "kw" > var< / span > card = < span class = "kw" > new< / span > < span class = "ty" > GameObject< / span > (song.title);
card.transform.< span class = "fn" > SetParent< / span > (cardContainer, worldPositionStays: < span class = "kw" > false< / span > ); < span class = "cmt" > // false = 로컬 유지< / span >
< span class = "kw" > var< / span > bg = card.< span class = "fn" > AddComponent< / span > < < span class = "ty" > Image< / span > > ();
< span class = "kw" > var< / span > btn = card.< span class = "fn" > AddComponent< / span > < < span class = "ty" > Button< / span > > ();
< span class = "cmt" > // 클로저 캡처 버그 방지 — foreach 루프 변수를 로컬에 복사< / span >
< span class = "ty" > SongInfo< / span > captured = song;
btn.onClick.< span class = "fn" > AddListener< / span > (() => < span class = "fn" > OnCardClicked< / span > (captured));< / pre >
< / div >
< div class = "point point-red" >
< div class = "label" > 중요 — 클로저 캡처 버그< / div >
< p >
< code > foreach< / code > 에서 람다로 루프 변수를 캡처하면, 루프가 끝난 뒤 모든 버튼이 < strong > 마지막 곡< / strong > 을 가리킨다.< br >
< code > SongInfo captured = song;< / code > 으로 로컬 복사본을 만들어야 각 클로저가 자신의 값을 가진다.< br >
C# 5 이후 < code > foreach< / code > 는 수정됐지만, < code > for(int i=0; ...)< / code > 에선 여전히 발생하므로 습관적으로 복사하는 게 좋다.
< / p >
< / div >
< h3 > 마퀴 스크롤 구조< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongSelectManager.cs — 마퀴 레이아웃< / span > < / div >
< pre > card
└─ TitleMask (< span class = "ty" > RectMask2D< / span > ) < span class = "cmt" > ← 클리핑 컨테이너< / span >
└─ Title (< span class = "ty" > TMP_Text< / span > + < span class = "ty" > MarqueeText< / span > ) < span class = "cmt" > ← 텍스트가 마스크 밖으로 스크롤< / span >
└─ Artist (< span class = "ty" > TMP_Text< / span > )
└─ Badge (< span class = "ty" > Image< / span > + < span class = "ty" > TMP_Text< / span > ) < span class = "cmt" > ← 다운로드된 곡에만 표시< / span > < / pre >
< / div >
< div class = "point point-blue" >
< div class = "label" > 학습 포인트 — RectMask2D< / div >
< p > Mask 컴포넌트와 달리 스텐실 버퍼를 쓰지 않아 오버헤드가 적다. RectTransform 영역 밖의 자식을 잘라낸다. 텍스트가 컨테이너 너비를 넘어도 잘리고, 자식이 스크롤하면 마퀴처럼 보인다.< / p >
< / div >
< h3 > 오프라인 폴백 (캐시)< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongSelectManager.cs — FetchSongs()< / span > < / div >
< pre > downloadManager.< span class = "fn" > FetchSongsList< / span > (
onSuccess: list => {
< span class = "fn" > SaveCache< / span > (list); < span class = "cmt" > // 성공하면 캐시 갱신< / span >
< span class = "fn" > RefreshCards< / span > ();
},
onError: _ => {
< span class = "ty" > SongsList< / span > cached = < span class = "fn" > LoadCache< / span > (); < span class = "cmt" > // 실패하면 캐시로 폴백< / span >
< span class = "kw" > if< / span > (cached != < span class = "kw" > null< / span > ) < span class = "fn" > RefreshCards< / span > ();
< span class = "kw" > else< / span > errorOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > ); < span class = "cmt" > // 캐시도 없으면 에러 표시< / span >
});< / pre >
< / div >
< / section >
<!-- ───────────────────────────────── 패턴 ── -->
< section id = "patterns" >
< h2 > 디자인 패턴 정리< / h2 >
< table >
< tr > < th > 패턴< / th > < th > 사용 위치< / th > < th > 설명< / th > < / tr >
< tr >
< td > Coroutine Chaining< / td >
< td > BeatSageUploader, SongController, NasPublisher< / td >
< td > < p > yield return StartCoroutine()으로 비동기 작업을 순차 실행. async/await 대신 Unity 방식.< / p > < / td >
< / tr >
< tr >
< td > Callback (Action)< / td >
< td > DownloadManager, BeatSageUploader, NasPublisher< / td >
< td > < p > onSuccess / onError / onProgress 콜백으로 코루틴 결과 전달.< / p > < / td >
< / tr >
< tr >
< td > Static Class (전역 상태)< / td >
< td > GameSession, BeatSageConverter, SongLibrary< / td >
< td > < p > GameSession은 씬 간 데이터 전달, BeatSageConverter는 순수 유틸리티.< / p > < / td >
< / tr >
< tr >
< td > Lookup Table< / td >
< td > SongController.CutDirMap, BeatSageUploader.DiffNames< / td >
< td > < p > static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.< / p > < / td >
< / tr >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< tr >
< td > Upsert< / td >
< td > NasPublisher.PatchSongsJson()< / td >
< td > < p > FindIndex로 기존 항목 교체, 없으면 추가. DB의 INSERT OR REPLACE와 동일.< / p > < / td >
< / tr >
< tr >
< td > Offline Fallback< / td >
< td > SongSelectManager.FetchSongs()< / td >
< td > < p > 네트워크 실패 시 로컬 캐시 JSON을 사용. 오프라인에서도 목록 표시 가능.< / p > < / td >
< / tr >
< tr >
< td > Idempotent Cache< / td >
< td > DownloadManager.DownloadSongCoroutine()< / td >
< td > < p > 파일 존재 여부 확인 후 스킵. 중복 다운로드 없음.< / p > < / td >
< / tr >
< / table >
< / section >
<!-- ───────────────────────────────── Unity 팁 ── -->
< section id = "unity-tips" >
< h2 > Unity 핵심 팁 (이 프로젝트에서 배우는 것)< / h2 >
< h3 > 1. UnityWebRequest 패턴< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > 일반 GET< / span > < / div >
< pre > < span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (url);
< span class = "kw" > yield return< / span > req.< span class = "fn" > SendWebRequest< / span > ();
< span class = "kw" > if< / span > (req.result != < span class = "ty" > UnityWebRequest< / span > .Result.Success) { < span class = "cmt" > /* 에러 처리 */< / span > }
< span class = "ty" > string< / span > text = req.downloadHandler.text;< / pre >
< / div >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > 오디오 클립 로드< / span > < / div >
< pre > < span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequestMultimedia< / span > .< span class = "fn" > GetAudioClip< / span > (< span class = "str" > "file://"< / span > + path, < span class = "ty" > AudioType< / span > .MPEG);
< span class = "kw" > yield return< / span > req.< span class = "fn" > SendWebRequest< / span > ();
< span class = "ty" > AudioClip< / span > clip = < span class = "ty" > DownloadHandlerAudioClip< / span > .< span class = "fn" > GetContent< / span > (req);< / pre >
< / div >
< h3 > 2. WaitUntil — 조건 기반 대기< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongController.cs< / span > < / div >
< pre > < span class = "cmt" > // 조건이 true가 될 때까지 매 프레임 체크< / span >
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitUntil< / span > (() => _audio.CurrentTime >= spawnAt);< / pre >
< / div >
< h3 > 3. LayoutRebuilder — 동적 UI 갱신< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SongSelectManager.cs< / span > < / div >
< pre > < span class = "cmt" > // 자식을 코드로 추가한 뒤 레이아웃 강제 재계산< / span >
< span class = "ty" > LayoutRebuilder< / span > .< span class = "fn" > ForceRebuildLayoutImmediate< / span > (cardContainer);
< span class = "ty" > Canvas< / span > .< span class = "fn" > ForceUpdateCanvases< / span > ();< / pre >
< / div >
< h3 > 4. using 선언 (C# 8)< / h3 >
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > C# 8 using 선언< / span > < / div >
< pre > < span class = "cmt" > // 블록 없이 — 변수가 선언된 스코프 끝에서 자동 Dispose< / span >
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (url);
< span class = "kw" > using var< / span > ms = < span class = "kw" > new< / span > < span class = "ty" > MemoryStream< / span > (bytes);
< span class = "cmt" > // 전통 방식 (C# 7 이하)< / span >
< span class = "kw" > using< / span > (< span class = "kw" > var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (url)) { ... }< / pre >
< / div >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< div class = "code-wrapper" >
< div class = "code-header" > < span class = "code-filename" > SaberGlow.cs 버그 사례< / span > < / div >
< pre > < span class = "cmt" > // 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함< / span >
< span class = "kw" > var< / span > light = _pointLight ?? < span class = "fn" > GetComponent< / span > < < span class = "ty" > Light< / span > > (); < span class = "cmt" > // ← 위험!< / span >
< span class = "cmt" > // 수정: 명시적 GetComponent만 사용< / span >
< span class = "kw" > var< / span > light = < span class = "fn" > GetComponent< / span > < < span class = "ty" > Light< / span > > ();
< span class = "kw" > if< / span > (light != < span class = "kw" > null< / span > ) { ... }< / pre >
< / div >
< div class = "point point-red" >
< div class = "label" > 중요 — Unity 오브젝트와 ??< / div >
< p > Unity의 < code > UnityEngine.Object< / code > 는 < code > ==< / code > 연산자를 오버로드해서 Destroyed 상태를 null처럼 처리한다. 하지만 C#의 < code > ??< / code > 는 CLR 레벨의 null만 체크하므로 Destroyed 오브젝트를 null로 인식하지 못한다. → Unity 오브젝트엔 < code > ??< / code > 대신 < code > if (x != null)< / code > 사용.< / p >
< / div >
< / section >
<!-- ───────────────────────────────── 퀴즈 ── -->
< section id = "quiz" >
< h2 > 셀프 퀴즈< / h2 >
< div class = "quiz" >
< div class = "q" > Q1. Beat Saber의 _time=8, BPM=120일 때 실제 재생 시간(초)은?< / div >
< button onclick = "this.nextElementSibling.style.display='block'" > 답 보기< / button >
< div class = "answer" > 8 * 60 / 120 = < strong > 4.0초< / strong > . 공식: time(초) = _time(비트) × 60 / BPM< / div >
< / div >
< div class = "quiz" >
< div class = "q" > Q2. travelTimeOverride가 필요한 이유는? 일반 노트에도 영향을 주는가?< / div >
< button onclick = "this.nextElementSibling.style.display='block'" > 답 보기< / button >
< div class = "answer" >
forEach로 동시 노트를 처리할 때 첫 노트와 두 번째 노트 사이에 1프레임(~16ms) 차이가 생긴다.
각 노트를 스폰하는 시점의 실제 남은 시간을 travelTime으로 주입하면 두 노트가 히트존에 동시 도착한다.
일반 노트는 스폰 시점의 remaining ≈ TargetTravelTime(1.8s)이므로 사실상 동일하다.
< / div >
< / div >
< div class = "quiz" >
< div class = "q" > Q3. SongSelectManager의 foreach 클로저 버그를 설명하고 해결책은?< / div >
< button onclick = "this.nextElementSibling.style.display='block'" > 답 보기< / button >
< div class = "answer" >
람다가 foreach 루프 변수를 캡처하면, 루프 완료 후 람다 실행 시점에 변수는 마지막 값을 가리킨다.
< code > SongInfo captured = song;< / code > 으로 로컬 복사본을 만들면 각 람다가 독립적인 값을 캡처한다.
< / div >
< / div >
< div class = "quiz" >
< div class = "q" > Q4. NasPublisher가 Unity의 기본 UnityWebRequest.Post(form) 대신 수동 multipart를 쓰는 이유는?< / div >
< button onclick = "this.nextElementSibling.style.display='block'" > 답 보기< / button >
< div class = "answer" >
Synology DSM이 Unity 기본 multipart 포맷을 거부하며 401 오류를 반환하기 때문이다.
GUID boundary, CRLF 줄바꿈, Content-Type 헤더를 직접 구성한 UploadHandlerRaw로 DSM 요구 형식을 맞춘다.
< / div >
< / div >
< div class = "quiz" >
< div class = "q" > Q5. static class GameSession의 한계는? 어떤 상황에서 문제가 생길 수 있나?< / div >
< button onclick = "this.nextElementSibling.style.display='block'" > 답 보기< / button >
< div class = "answer" >
앱 프로세스가 살아있는 동안만 유지된다. 앱을 강제 종료했다가 재시작하면 값이 사라진다.
또한 Game 씬을 에디터에서 직접 Play하면 GameSession이 null이라 오류가 나 — 항상 SongSelect 씬부터 시작해야 한다.
< / div >
< / div >
< / section >
<!-- ───────────────────────────────── 남은 작업 ── -->
< section id = "todo" >
< h2 > 남은 작업< / h2 >
< ul class = "checklist" >
< li class = "done" > SongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인< / li >
< li class = "done" > SongSelect 씬 — 카드 목록 + 다운로드 + 플레이< / li >
< li class = "done" > Game 씬 — SongController, 카운트다운, 큐브 스폰< / li >
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< / ul >
< / section >
< / div >
< script >
// 사이드바 active 하이라이트
const sections = document . querySelectorAll ( 'section[id]' ) ;
const links = document . querySelectorAll ( '#sidebar a' ) ;
const io = new IntersectionObserver ( entries => {
entries . forEach ( e => {
if ( e . isIntersecting ) {
links . forEach ( a => {
a . classList . toggle ( 'active' , a . getAttribute ( 'href' ) === '#' + e . target . id ) ;
} ) ;
}
} ) ;
} , { rootMargin : '-20% 0px -70% 0px' } ) ;
sections . forEach ( s => io . observe ( s ) ) ;
< / script >
< / body >
< / html >