2026-05-26 00:18:32 +09:00
<!DOCTYPE html>
< html lang = "ko" >
< head >
< meta charset = "UTF-8" >
< title > VR Beat Saber — 주석 코드 전집< / title >
< style >
: root { --bg : #0d1117 ; --sf : #161b22 ; --sf2 : #1c2230 ; --bd : #30363d ; --ac : #58a6ff ; --gn : #3fb950 ; --ye : #d29922 ; --rd : #f85149 ; --pu : #bc8cff ; --or : #ffa657 ; --tx : #e6edf3 ; --mu : #8b949e }
* { box-sizing : border-box ; margin : 0 ; padding : 0 }
body { background : var ( - - bg ) ; color : var ( - - tx ) ; font-family : 'Segoe UI' , system-ui , sans-serif ; font-size : 13 px ; display : flex ; height : 100 vh ; overflow : hidden }
/* 사이드바 */
# sb { width : 220 px ; min-width : 220 px ; background : var ( - - sf ) ; border-right : 1 px solid var ( - - bd ) ; overflow-y : auto ; padding : 12 px 0 ; display : flex ; flex-direction : column ; gap : 2 px }
# sb h2 { font-size : 10 px ; text-transform : uppercase ; letter-spacing : .1 em ; color : var ( - - bd ) ; padding : 10 px 14 px 4 px }
. tab-btn { display : block ; padding : 6 px 14 px ; color : var ( - - mu ) ; text-align : left ; background : none ; border : none ; border-left : 2 px solid transparent ; cursor : pointer ; font-size : 12 px ; width : 100 % ; transition : all .12 s }
. tab-btn : hover { color : var ( - - tx ) ; background : rgba ( 255 , 255 , 255 , .04 ) }
. tab-btn . active { color : var ( - - ac ) ; border-left-color : var ( - - ac ) ; background : rgba ( 88 , 166 , 255 , .08 ) }
/* 메인 */
# main { flex : 1 ; overflow-y : auto ; padding : 32 px 40 px }
/* 파일 패널 */
. panel { display : none } . panel . active { display : block }
. file-header { margin-bottom : 20 px ; padding-bottom : 16 px ; border-bottom : 1 px solid var ( - - bd ) }
. file-header h1 { font-size : 22 px ; font-weight : 700 }
. file-header p { color : var ( - - mu ) ; margin-top : 6 px ; line-height : 1.6 }
/* 코드 */
. cw { background : #0a0e14 ; border : 1 px solid var ( - - bd ) ; border-radius : 8 px ; overflow : hidden ; margin : 14 px 0 }
. ch { background : var ( - - sf ) ; padding : 7 px 14 px ; border-bottom : 1 px solid var ( - - bd ) ; font-size : 11 px ; color : var ( - - mu ) ; display : flex ; justify-content : space-between }
. ch span : first-child { color : var ( - - tx ) ; font-weight : 600 }
pre { padding : 14 px 18 px ; overflow-x : auto ; font-family : 'Cascadia Code' , 'Fira Code' , Consolas , monospace ; font-size : 12 px ; line-height : 1.75 ; tab-size : 4 }
/* 토큰 */
. kw { color : #ff7b72 } . ty { color : #ffa657 } . fn { color : #d2a8ff } . st { color : #a5d6ff } . nm { color : #79c0ff } . cm { color : #6e7681 ; font-style : italic } . op { color : #ff7b72 } . va { color : #e6edf3 }
/* 어노테이션 */
. ann { color : #e3b341 ; font-style : italic ; font-weight : 500 } /* 노란색 = 설명 */
. why { color : #56d364 ; font-style : italic } /* 초록 = 이유/배경 */
. bug { color : #f85149 ; font-style : italic } /* 빨간 = 주의/버그 */
/* 콜아웃 */
. box { border-left : 3 px solid ; padding : 10 px 14 px ; border-radius : 0 6 px 6 px 0 ; margin : 10 px 0 ; font-size : 12.5 px }
. box-b { border-color : var ( - - ac ) ; background : rgba ( 88 , 166 , 255 , .06 ) }
. box-g { border-color : var ( - - gn ) ; background : rgba ( 63 , 185 , 80 , .06 ) }
. box-y { border-color : var ( - - ye ) ; background : rgba ( 210 , 153 , 34 , .06 ) }
. box-r { border-color : var ( - - rd ) ; background : rgba ( 248 , 81 , 73 , .06 ) }
. box . lbl { font-size : 10 px ; font-weight : 700 ; text-transform : uppercase ; letter-spacing : .08 em ; margin-bottom : 4 px }
. box-b . lbl { color : var ( - - ac ) } . box-g . lbl { color : var ( - - gn ) } . box-y . lbl { color : var ( - - ye ) } . box-r . lbl { color : var ( - - rd ) }
. box p { color : var ( - - tx ) ; margin : 0 ; line-height : 1.6 }
h3 { font-size : 14 px ; font-weight : 700 ; color : var ( - - ac ) ; margin : 24 px 0 8 px }
h4 { font-size : 12 px ; font-weight : 700 ; color : var ( - - mu ) ; text-transform : uppercase ; letter-spacing : .06 em ; margin : 18 px 0 6 px }
< / style >
< / head >
< body >
< nav id = "sb" >
< h2 > 데이터 모델< / h2 >
< button class = "tab-btn active" onclick = "show('notedata')" > NoteData.cs< / button >
< button class = "tab-btn" onclick = "show('gamesession')" > GameSession.cs< / button >
< h2 > 변환 / API< / h2 >
< button class = "tab-btn" onclick = "show('converter')" > BeatSageConverter.cs< / button >
< button class = "tab-btn" onclick = "show('uploader')" > BeatSageUploader.cs< / button >
< h2 > 네트워크 / 파일< / h2 >
< button class = "tab-btn" onclick = "show('naspub')" > NasPublisher.cs< / button >
< button class = "tab-btn" onclick = "show('dlmgr')" > DownloadManager.cs< / button >
< h2 > 게임 씬< / h2 >
< button class = "tab-btn" onclick = "show('songctrl')" > SongController.cs< / button >
< button class = "tab-btn" onclick = "show('audiomgr')" > AudioManager.cs< / button >
< h2 > UI< / h2 >
< button class = "tab-btn" onclick = "show('creator')" > SongCreatorManager.cs< / button >
< 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 >
2026-05-26 19:12:06 +09:00
< button class = "tab-btn" onclick = "show('vrpointer')" > VRPointerController.cs< / button >
< button class = "tab-btn" onclick = "show('vrptrsetup')" > VRPointerSetup.cs< / button >
2026-05-26 00:18:32 +09:00
< h2 > 유틸< / h2 >
< button class = "tab-btn" onclick = "show('songlibrary')" > SongLibrary.cs< / button >
< button class = "tab-btn" onclick = "show('desktop')" > DesktopUIMode.cs< / button >
< button class = "tab-btn" onclick = "show('xrloader')" > XRSimulatorLoader.cs< / button >
< / nav >
< div id = "main" >
<!-- ══════════════════════ NoteData.cs ══════════════════════ -->
< div id = "p-notedata" class = "panel active" >
< div class = "file-header" >
< h1 > NoteData.cs< / h1 >
< p > 프로젝트 전체에서 쓰는 < strong > 데이터 구조(DTO)< / strong > 를 모아놓은 파일. 로직은 없고 순수하게 데이터만 담는다.< / p >
< / div >
< div class = "box box-b" > < div class = "lbl" > 이 파일의 역할< / div > < p > NAS의 JSON ↔ C# 객체 ↔ Beat Saber .dat 사이의 데이터 형식을 정의한다. Unity의 < code > JsonUtility< / code > 가 읽고 쓸 수 있도록 < code > [Serializable]< / code > 이 붙는다.< / p > < / div >
< div class = "cw" > < div class = "ch" > < span > NoteData.cs< / span > < / div > < pre >
< span class = "kw" > using< / span > System;
< span class = "kw" > using< / span > System.Collections.Generic;
< span class = "kw" > using< / span > UnityEngine;
< span class = "ann" > // [Serializable] = JsonUtility가 이 클래스를 JSON으로 변환/역변환할 수 있게 허용하는 속성< / span >
< span class = "ann" > // 붙이지 않으면 JsonUtility.ToJson()이 빈 {} 를 반환한다< / span >
[< span class = "ty" > Serializable< / span > ]
< span class = "kw" > public class< / span > < span class = "ty" > NoteData< / span >
{
< span class = "ann" > // Beat Sage가 반환하는 .dat 파일의 _time(비트단위)을 BPM으로 나눠 초 단위로 변환한 값< / span >
< span class = "ann" > // → 이 time은 "음악 시작 후 몇 초에 이 노트를 쳐야 하나"< / span >
< span class = "kw" > public< / span > < span class = "ty" > float< / span > time;
< span class = "ann" > // 가로 열 위치: 0(왼쪽) ~ 3(오른쪽). Beat Saber 공식 4열 그리드< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > position;
< span class = "ann" > // 세로 행 위치: 0(아래) ~ 2(위). Beat Saber 공식 3행 그리드< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > lineLayer;
< span class = "ann" > // 0 = 빨간 블록(왼손 사이버), 1 = 파란 블록(오른손 사이버)< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > colorType;
< span class = "ann" > // 어느 방향으로 칼날을 휘둘러야 하는지< / span >
< span class = "ann" > // 0=위 1=아래 2=왼 3=오른 4=왼위 5=오른위 6=왼아래 7=오른아래 8=아무방향(점)< / span >
< span class = "kw" > public< / span > < span class = "ty" > int< / span > cutDirection;
}
< span class = "ann" > // 맵 JSON 파일의 최상위 구조 — { "target": [ ...NoteData 배열... ] }< / span >
[< span class = "ty" > Serializable< / span > ]
< span class = "kw" > public class< / span > < span class = "ty" > MapData< / span >
{
< span class = "ann" > // "target"이라는 이름으로 저장된다. JsonUtility는 필드명 = JSON 키< / span >
< span class = "kw" > public< / span > < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > target;
}
< span class = "ann" > // NAS의 songs.json 전체 구조 — { "version": "1.0", "songs": [...] }< / span >
[< span class = "ty" > Serializable< / span > ]
< span class = "kw" > public class< / span > < span class = "ty" > SongsList< / span >
{
< span class = "kw" > public< / span > < span class = "ty" > string< / span > version;
< span class = "kw" > public< / span > < span class = "ty" > List< / span > < < span class = "ty" > SongInfo< / span > > songs;
}
< span class = "ann" > // 곡 하나의 메타데이터. songs.json의 각 원소이자 GameSession으로 씬 간 전달되는 핵심 객체< / span >
[< span class = "ty" > Serializable< / span > ]
< span class = "kw" > public class< / span > < span class = "ty" > SongInfo< / span >
{
< span class = "ann" > // id = 곡의 유일 식별자. 파일 경로/캐시 폴더명에도 사용된다< / span >
< span class = "ann" > // 예: "my_song" → 캐시 폴더: temporaryCachePath/beatsaber/my_song/< / span >
< span class = "kw" > public< / span > < span class = "ty" > string< / span > id;
< span class = "kw" > public< / span > < span class = "ty" > string< / span > title;
< span class = "kw" > public< / span > < span class = "ty" > string< / span > artist;
< span class = "kw" > public< / span > < span class = "ty" > float< / span > bpm;
< span class = "kw" > public< / span > < span class = "ty" > int< / span > duration; < span class = "ann" > // 초 단위 길이 (현재는 사용 안 함)< / span >
< span class = "ann" > // audioFile = NAS 상의 상대 경로. 예: "music/my_song.mp3"< / span >
< span class = "ann" > // DownloadManager가 baseUrl + audioFile 로 전체 URL을 조합한다< / span >
< span class = "kw" > public< / span > < span class = "ty" > string< / span > audioFile;
< span class = "kw" > public< / span > < span class = "ty" > long< / span > audioSize;
< span class = "kw" > public< / span > < span class = "ty" > string< / span > coverImage;
< span class = "kw" > public< / span > < span class = "ty" > DifficultyMap< / span > difficulties; < span class = "ann" > // 난이도별 맵 파일 정보< / span >
< span class = "kw" > public< / span > < span class = "ty" > string< / span > addedAt; < span class = "ann" > // "2026-05-22" 형식< / span >
}
< span class = "ann" > // 4가지 난이도를 각각 필드로 가지는 구조< / span >
< span class = "ann" > // Dictionary를 쓰면 더 유연하지만, JsonUtility는 Dictionary를 직렬화 못한다 → 필드로 하드코딩< / span >
[< span class = "ty" > Serializable< / 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 = "ann" > // 문자열 key로 해당 난이도 정보를 꺼내는 헬퍼 메서드< / span >
< span class = "ann" > // C# 8의 switch expression: 중괄호 없이 => 로 값을 반환하는 switch< / span >
< span class = "ann" > // _ = default case. 알 수 없는 난이도면 null 반환< / span >
< 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 = "st" > "normal"< / span > < span class = "op" > =>< / span > normal,
< span class = "st" > "hard"< / span > < span class = "op" > =>< / span > hard,
< span class = "st" > "expert"< / span > < span class = "op" > =>< / span > expert,
< span class = "st" > "expertplus"< / span > < span class = "op" > =>< / span > expertplus,
_ < span class = "op" > =>< / span > < span class = "kw" > null< / span >
};
}
< span class = "ann" > // 난이도 하나의 파일 정보. mapFile은 NAS 상대경로. 예: "maps/Map_my_song_hard.json"< / span >
[< span class = "ty" > Serializable< / span > ]
< span class = "kw" > public class< / span > < span class = "ty" > DifficultyInfo< / span >
{
< span class = "ann" > // NasPublisher.AssignMapFile()이 업로드 후 이 값을 채운다< / span >
< span class = "kw" > public< / span > < span class = "ty" > string< / span > mapFile;
< span class = "kw" > public< / span > < span class = "ty" > long< / span > mapSize;
< span class = "kw" > public< / span > < span class = "ty" > int< / span > noteCount; < span class = "ann" > // 해당 난이도의 총 노트 수< / span >
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ GameSession.cs ══════════════════════ -->
< div id = "p-gamesession" class = "panel" >
< div class = "file-header" >
< h1 > GameSession.cs< / h1 >
< p > SongSelect → Game 씬으로 "어떤 곡을 어떤 난이도로 플레이할지" 정보를 전달하는 전역 컨테이너.< / p >
< / div >
< div class = "box box-y" > < div class = "lbl" > 씬 전환과 데이터 전달 문제< / div > < p > Unity는 씬이 바뀌면 모든 GameObject가 파괴된다. 씬 간 데이터를 넘기려면 파괴되지 않는 곳에 저장해야 한다. static 클래스는 앱 프로세스가 살아있는 동안 메모리에 남기 때문에 가장 단순한 해법이다.< / p > < / div >
< div class = "cw" > < div class = "ch" > < span > GameSession.cs< / span > < / div > < pre >
< span class = "ann" > // static class = 인스턴스를 만들 수 없다 (new GameSession() 불가)< / span >
< span class = "ann" > // 모든 멤버가 자동으로 static → 어디서든 GameSession.SelectedSong 으로 접근< / span >
< span class = "ann" > // MonoBehaviour가 아니므로 씬 전환으로 파괴되지 않는다< / span >
< span class = "kw" > public static class< / span > < span class = "ty" > GameSession< / span >
{
< span class = "ann" > // SongDetailPanel.OnPlayClicked()에서 여기에 저장하고< / span >
< span class = "ann" > // SongController.LoadAndPlay()에서 여기서 읽는다< / 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; < span class = "ann" > // "normal" / "hard" / "expert" / "expertplus"< / span >
}
< span class = "ann" > // ⚠️ 한계: Game 씬을 에디터에서 직접 Play하면 이 값이 null → SongController가 오류< / span >
< span class = "ann" > // 항상 Menu/SongSelect 씬부터 시작해야 한다< / span >
< / pre > < / div >
< / div >
<!-- ══════════════════════ BeatSageConverter.cs ══════════════════════ -->
< div id = "p-converter" class = "panel" >
< div class = "file-header" >
< h1 > BeatSageConverter.cs< / h1 >
< p > Beat Sage AI가 반환하는 < code > .dat< / code > JSON 포맷을 우리 내부 < code > NoteData< / code > 로 변환하는 순수 유틸리티.< / p >
< / div >
< div class = "box box-b" > < div class = "lbl" > Beat Saber .dat 포맷이란?< / div > < p > Beat Saber 공식 커스텀 맵 포맷. < code > _time< / code > 은 비트(beat) 단위이고 BPM으로 나눠야 실제 초가 된다. < code > _type< / code > 은 0=빨강, 1=파랑, 3=폭탄(스킵). 우리 게임은 NoteData 포맷을 쓰므로 변환이 필요하다.< / p > < / div >
< div class = "cw" > < div class = "ch" > < span > BeatSageConverter.cs< / span > < / div > < pre >
< span class = "ann" > // [Serializable] + 필드명 앞에 _ = Beat Saber .dat JSON 키와 정확히 일치해야 JsonUtility가 파싱< / span >
[< span class = "ty" > System.Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > SongMetadata< / span >
{
< span class = "kw" > public string< / span > title; < span class = "kw" > public string< / span > artist; < span class = "kw" > public float< / span > bpm;
}
< span class = "ann" > // info.dat 파일 구조 — Beat Sage가 ZIP에 포함하는 메타데이터 파일< / span >
[< span class = "ty" > System.Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > BeatSageInfoDat< / span >
{
< span class = "kw" > public string< / span > _songName; < span class = "ann" > // JSON 키 "_songName"< / span >
< span class = "kw" > public string< / span > _songAuthorName; < span class = "ann" > // JSON 키 "_songAuthorName"< / span >
< span class = "kw" > public float< / span > _beatsPerMinute; < span class = "ann" > // Beat Sage가 오디오에서 자동 감지한 BPM< / span >
}
< span class = "ann" > // .dat 파일 최상위 구조< / span >
[< span class = "ty" > System.Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > BeatSageRoot< / span >
{
< span class = "kw" > public string< / span > _version;
< span class = "kw" > public< / span > < span class = "ty" > List< / span > < < span class = "ty" > BeatSageNote< / span > > _notes; < span class = "ann" > // 노트 배열< / span >
}
[< span class = "ty" > System.Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > BeatSageNote< / span >
{
< span class = "kw" > public float< / span > _time; < span class = "ann" > // 비트 단위 시간< / span >
< span class = "kw" > public int< / span > _lineIndex; < span class = "ann" > // 열 0-3< / span >
< span class = "kw" > public int< / span > _lineLayer; < span class = "ann" > // 행 0-2< / span >
< span class = "kw" > public int< / span > _type; < span class = "ann" > // 0=빨강 1=파랑 3=폭탄< / span >
< span class = "kw" > public int< / span > _cutDirection; < span class = "ann" > // 0-8< / span >
}
< span class = "ann" > // static class = 상태(필드) 없이 순수 함수만 모아놓는 유틸리티 패턴< / span >
< span class = "ann" > // 인스턴스 없이 BeatSageConverter.Convert() 로 바로 호출< / span >
< span class = "kw" > public static class< / span > < span class = "ty" > BeatSageConverter< / span >
{
< span class = "ann" > // rawJson: .dat 파일 전체 문자열 / bpm: info.dat에서 읽은 BPM< / span >
< span class = "kw" > public static< / span > < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > < span class = "fn" > Convert< / span > (< span class = "ty" > string< / span > rawJson, < span class = "ty" > float< / span > bpm)
{
< span class = "kw" > var< / span > result = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > ();
< span class = "kw" > var< / span > root = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > BeatSageRoot< / span > > (rawJson);
< span class = "ann" > // ?. = null 조건부 접근. root가 null이거나 _notes가 null이면 null 반환 → if가 잡아냄< / span >
< span class = "kw" > if< / span > (root?._notes == < span class = "kw" > null< / span > )
{
Debug.< span class = "fn" > LogWarning< / span > (< span class = "st" > "[BeatSageConverter] Parse failed or no notes."< / span > );
< span class = "kw" > return< / span > result; < span class = "ann" > // 빈 리스트 반환 (null이 아님 → 호출부에서 null 체크 불필요)< / span >
}
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > note < span class = "kw" > in< / span > root._notes)
{
< span class = "ann" > // 폭탄(_type=3), 벽 등은 스킵. 우리는 일반 블록(0,1)만 처리< / span >
< span class = "kw" > if< / span > (note._type != < span class = "nm" > 0< / span > & & note._type != < span class = "nm" > 1< / span > ) < span class = "kw" > continue< / span > ;
result.< span class = "fn" > Add< / span > (< span class = "kw" > new< / span > < span class = "ty" > NoteData< / span >
{
< span class = "ann" > // ★ 핵심 변환: 비트 → 초< / span >
< span class = "ann" > // 공식: 초 = 비트 × 60 / BPM< / span >
< span class = "ann" > // 예) BPM=120, _time=8 → 8× 60/120 = 4.0초< / span >
time = (note._time * < span class = "nm" > 60f< / span > ) / bpm,
position = note._lineIndex,
lineLayer = note._lineLayer,
colorType = note._type,
cutDirection = note._cutDirection,
});
}
Debug.< span class = "fn" > Log< / span > ($< span class = "st" > "[BeatSageConverter] Converted {result.Count} notes."< / span > );
< span class = "kw" > return< / span > result;
}
< span class = "ann" > // NoteData 리스트를 우리 맵 JSON 형식으로 직렬화< / span >
< span class = "ann" > // true = prettyPrint (들여쓰기 포함 → 사람이 읽기 좋음)< / span >
< span class = "kw" > public static string< / span > < span class = "fn" > ToMapJson< / span > (< span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > notes)
< span class = "op" > =>< / span > JsonUtility.< span class = "fn" > ToJson< / span > (< span class = "kw" > new< / span > < span class = "ty" > MapData< / span > { target = notes }, prettyPrint: < span class = "kw" > true< / span > );
< span class = "ann" > // info.dat JSON을 파싱해서 제목/아티스트/BPM 추출< / span >
< 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 >
{
< span class = "ann" > // ?? "" = null이면 빈 문자열로 대체 (NullReferenceException 방지)< / span >
< span class = "ann" > // .Trim() = 앞뒤 공백 제거 (Beat Sage가 가끔 공백을 넣음)< / span >
title = (info._songName ?? < span class = "st" > ""< / span > ).< span class = "fn" > Trim< / span > (),
artist = (info._songAuthorName ?? < span class = "st" > ""< / span > ).< span class = "fn" > Trim< / span > (),
bpm = info._beatsPerMinute,
};
}
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ BeatSageUploader.cs ══════════════════════ -->
< div id = "p-uploader" class = "panel" >
< div class = "file-header" >
< h1 > BeatSageUploader.cs< / h1 >
< p > Beat Sage 서버에 오디오를 올리고, 비트맵 생성 완료를 폴링하고, 결과 ZIP을 받아 변환하는 전체 파이프라인.< / p >
< / div >
< div class = "box box-b" > < div class = "lbl" > 전체 흐름 (4단계)< / div > < p > [1/4] POST 오디오 → levelId 획득 → [2/4] GET /heartbeat 5초마다 폴링 → [3/4] GET /download ZIP 다운로드 → [4/4] ZIP 해제 + .dat → NoteData 변환< / p > < / div >
< div class = "cw" > < div class = "ch" > < span > BeatSageUploader.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > BeatSageUploader< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // const = 컴파일 타임 상수. 런타임에 변경 불가. 문자열 상수는 항상 const로 선언하는 게 관례< / span >
< span class = "kw" > private const string< / span > BASE_URL = < span class = "st" > "https://beatsaber.com"< / span > ;
< span class = "kw" > private const string< / span > CREATE_EP = < span class = "st" > "/beatsaber_custom_level_create"< / span > ;
< span class = "kw" > private const string< / span > HEARTBEAT_EP = < span class = "st" > "/beatsaber_custom_level_heartbeat/{0}"< / span > ; < span class = "ann" > // {0} = levelId< / span >
< span class = "kw" > private const string< / span > DOWNLOAD_EP = < span class = "st" > "/beatsaber_custom_level_download/{0}"< / span > ;
< span class = "kw" > private const float< / span > POLL_INTERVAL = < span class = "nm" > 5f< / span > ; < span class = "ann" > // 5초마다 heartbeat 체크< / span >
< span class = "kw" > private const float< / span > POLL_TIMEOUT = < span class = "nm" > 300f< / span > ; < span class = "ann" > // 최대 5분 대기. 초과하면 오류 처리< / span >
< span class = "ann" > // Beat Sage API는 "Normal", "Hard"처럼 파스칼케이스를 요구< / span >
< span class = "ann" > // 우리 내부는 "normal", "hard" 소문자 사용 → 이 딕셔너리로 매핑< / span >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > string< / span > > DiffNames = < span class = "kw" > new< / span > ()
{
{ < span class = "st" > "normal"< / span > , < span class = "st" > "Normal"< / span > }, { < span class = "st" > "hard"< / span > , < span class = "st" > "Hard"< / span > },
{ < span class = "st" > "expert"< / span > , < span class = "st" > "Expert"< / span > }, { < span class = "st" > "expertplus"< / span > , < span class = "st" > "ExpertPlus"< / span > },
};
< span class = "ann" > // ZIP 안의 파일명 매핑 (대소문자 정확히 일치해야 ZipArchive에서 찾음)< / span >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > string< / span > > DatFileNames = < span class = "kw" > new< / span > ()
{
{ < span class = "st" > "normal"< / span > , < span class = "st" > "Normal.dat"< / span > }, { < span class = "st" > "hard"< / span > , < span class = "st" > "Hard.dat"< / span > },
{ < span class = "st" > "expert"< / span > , < span class = "st" > "Expert.dat"< / span > }, { < span class = "st" > "expertplus"< / span > , < span class = "st" > "ExpertPlus.dat"< / span > },
};
< span class = "ann" > // { get; private set; } = 외부에서 읽기만 가능, 쓰기는 이 클래스 내부만 가능< / span >
< span class = "ann" > // SongCreatorManager가 UI에 상태 텍스트를 표시하기 위해 읽는다< / span >
< span class = "kw" > public string< / span > CurrentStatus { < span class = "kw" > get< / span > ; < span class = "kw" > private set< / span > ; } = < span class = "st" > ""< / span > ;
< span class = "kw" > public< / span > < span class = "ty" > SongMetadata< / span > LastMetadata { < span class = "kw" > get< / span > ; < span class = "kw" > private set< / span > ; } < span class = "ann" > // info.dat에서 읽은 제목/아티스트/BPM< / span >
< span class = "ann" > // ── 공개 API: 로컬 파일 업로드 ──────────────────────────────< / span >
< span class = "ann" > // IEnumerator = 코루틴. yield return으로 Unity에게 "여기서 한 프레임 쉬고 와"를 알림< / span >
< span class = "ann" > // Action< T> = 반환값 없는 콜백 델리게이트. 코루틴은 값을 return 못하므로 콜백으로 결과 전달< / span >
< span class = "kw" > public< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Upload< / span > (
< span class = "ty" > string< / span > audioPath, < span class = "ann" > // 로컬 MP3 경로< / span >
< span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties, < span class = "ann" > // ["normal","hard",...]< / span >
< span class = "ty" > float< / span > bpm, < span class = "ann" > // UI에서 입력한 BPM (fallback)< / span >
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress, < span class = "ann" > // 0.0~1.0 진행률 콜백< / span >
< span class = "ty" > Action< / span > < < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > > onComplete, < span class = "ann" > // 성공: 변환된 맵 전달< / span >
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError) < span class = "ann" > // 실패: 오류 메시지 전달< / span >
{
< span class = "fn" > SetStatus< / span > (< span class = "st" > "[1/4] Uploading audio..."< / span > );
< span class = "ty" > string< / span > levelId = < span class = "kw" > null< / span > ;
< span class = "ann" > // yield return StartCoroutine() = 내부 코루틴이 끝날 때까지 여기서 기다린다< / span >
< span class = "ann" > // levelId는 람다(id => levelId = id)를 통해 값을 받아온다< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > CreateLevel< / span > (audioPath, difficulties, id => levelId = id, onError);
< span class = "kw" > if< / span > (levelId == < span class = "kw" > null< / span > ) < span class = "kw" > yield break< / span > ; < span class = "ann" > // CreateLevel이 오류를 호출했으면 중단< / span >
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.15f< / span > );
< span class = "kw" > yield return< / span > < span class = "fn" > PollAndDownload< / span > (levelId, difficulties, bpm, onProgress, onComplete, onError);
}
< span class = "ann" > // URL 업로드는 오디오 파일 대신 URL 문자열을 전송 → Beat Sage 서버가 직접 다운로드< / span >
< span class = "ann" > // 이후 PollAndDownload는 동일하므로 공통 메서드로 분리되어 있음< / span >
< span class = "kw" > public< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > UploadFromUrl< / span > (
< span class = "ty" > string< / span > audioUrl, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties, < span class = "ty" > float< / span > bpm,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress,
< span class = "ty" > Action< / span > < < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > > onComplete,
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "fn" > SetStatus< / span > (< span class = "st" > "[1/4] Sending URL to Beat Sage..."< / span > );
< span class = "ty" > string< / span > levelId = < span class = "kw" > null< / span > ;
< span class = "kw" > yield return< / span > < span class = "fn" > CreateLevelFromUrl< / span > (audioUrl, difficulties, id => levelId = id, onError);
< span class = "kw" > if< / span > (levelId == < span class = "kw" > null< / span > ) < span class = "kw" > yield break< / span > ;
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.15f< / span > );
< span class = "kw" > yield return< / span > < span class = "fn" > PollAndDownload< / span > (levelId, difficulties, bpm, onProgress, onComplete, onError);
}
< span class = "ann" > // ── 2~4단계 공통 파이프라인 ──────────────────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > PollAndDownload< / span > (
< span class = "ty" > string< / span > levelId, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties, < span class = "ty" > float< / span > bpm,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress,
< span class = "ty" > Action< / span > < < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > > onComplete,
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "fn" > SetStatus< / span > (< span class = "st" > "[2/4] Generating beatmap..."< / span > );
< span class = "kw" > bool< / span > ready = < span class = "kw" > false< / span > ;
< span class = "kw" > float< / span > elapsed = < span class = "nm" > 0f< / span > ;
< span class = "ann" > // Beat Sage 생성 완료 폴링: 5초마다 상태 확인, 최대 300초 대기< / span >
< span class = "kw" > while< / span > (!ready & & elapsed < POLL_TIMEOUT)
{
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (POLL_INTERVAL); < span class = "ann" > // 5초 대기 (Unity 메인 스레드 블로킹 없음)< / span >
elapsed += POLL_INTERVAL;
< span class = "kw" > bool< / span > error = < span class = "kw" > false< / span > ;
< span class = "kw" > yield return< / span > < span class = "fn" > PollHeartbeat< / span > (levelId,
status => {
< span class = "ann" > // OrdinalIgnoreCase = 대소문자 무시 비교. "Generated" vs "generated" 둘 다 인식< / span >
ready = string.< span class = "fn" > Equals< / span > (status, < span class = "st" > "generated"< / span > , StringComparison.OrdinalIgnoreCase)
|| string.< span class = "fn" > Equals< / span > (status, < span class = "st" > "done"< / span > , StringComparison.OrdinalIgnoreCase);
error = string.< span class = "fn" > Equals< / span > (status, < span class = "st" > "error"< / span > , StringComparison.OrdinalIgnoreCase);
},
onError);
< span class = "kw" > if< / span > (error) { onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "Beat Sage generation failed"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // 진행률: 15%~75% 구간을 폴링 시간 비율로 채움< / span >
< span class = "ann" > // Clamp01 = 0~1 사이로 클램프 (elapsed가 POLL_TIMEOUT 초과해도 1.0 이상 안 됨)< / span >
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.15f< / span > + Mathf.< span class = "fn" > Clamp01< / span > (elapsed / POLL_TIMEOUT) * < span class = "nm" > 0.6f< / span > );
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "[2/4] Generating... {(int)elapsed}s elapsed"< / span > );
}
< span class = "kw" > if< / span > (!ready) { onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "Beat Sage timeout (> 5 min)"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "fn" > SetStatus< / span > (< span class = "st" > "[3/4] Downloading result..."< / span > );
< span class = "kw" > byte< / span > [] zipBytes = < span class = "kw" > null< / span > ;
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadZip< / span > (levelId, b => zipBytes = b, onError);
< span class = "kw" > if< / span > (zipBytes == < span class = "kw" > null< / span > ) < span class = "kw" > yield break< / span > ;
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.9f< / span > );
< span class = "fn" > SetStatus< / span > (< span class = "st" > "[3/4] Converting map data..."< / span > );
< span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > maps = < span class = "kw" > null< / span > ;
< span class = "ann" > // try-catch: ZIP 파싱/변환은 동기 작업이므로 코루틴 밖에서 예외가 바로 던져짐< / span >
< span class = "ann" > // 코루틴 안에서 예외가 나면 Unity가 조용히 중단하므로 명시적으로 잡아야 한다< / span >
< span class = "kw" > try< / span > { maps = < span class = "fn" > ExtractAndConvert< / span > (zipBytes, difficulties, bpm); }
< span class = "kw" > catch< / span > (< span class = "ty" > Exception< / span > e) { onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Conversion failed: {e.Message}"< / span > ); < span class = "kw" > yield break< / span > ; }
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 1f< / span > );
onComplete?.< span class = "fn" > Invoke< / span > (maps); < span class = "ann" > // 성공! 변환된 맵을 SongCreatorManager에 전달< / span >
}
< span class = "ann" > // ── CreateLevel: 파일을 multipart/form-data로 POST ──────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > CreateLevel< / span > (< span class = "ty" > string< / span > audioPath, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties,
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onSuccess, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "kw" > byte< / span > [] audioBytes = File.< span class = "fn" > ReadAllBytes< / span > (audioPath); < span class = "ann" > // 파일 전체를 메모리로 읽음< / span >
< span class = "kw" > string< / span > fileName = Path.< span class = "fn" > GetFileName< / span > (audioPath);
< span class = "ann" > // 내부 "normal" 키를 API가 요구하는 "Normal" 등으로 변환< / span >
< span class = "kw" > var< / span > mappedDiffs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > ();
< span class = "kw" > foreach< / span > (< span class = "ty" > string< / span > d < span class = "kw" > in< / span > difficulties)
< span class = "kw" > if< / span > (DiffNames.< span class = "fn" > TryGetValue< / span > (d, < span class = "kw" > out var< / span > n)) mappedDiffs.< span class = "fn" > Add< / span > (n);
< span class = "kw" > if< / span > (mappedDiffs.Count == < span class = "nm" > 0< / span > ) { onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "No supported difficulties."< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // IMultipartFormSection 리스트로 form-data 구성< / span >
< span class = "ann" > // MultipartFormFileSection = 파일 파트 (Content-Disposition: form-data; name=".."; filename="..")< / span >
< span class = "ann" > // MultipartFormDataSection = 텍스트 파트< / span >
< span class = "kw" > var< / span > form = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > IMultipartFormSection< / span > >
{
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormFileSection< / span > (< span class = "st" > "audio_file"< / span > , audioBytes, fileName, < span class = "st" > "audio/mpeg"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "audio_metadata_title"< / span > , < span class = "st" > " "< / span > ), < span class = "ann" > // Beat Sage가 info.dat로 자동 감지< / span >
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "audio_metadata_artist"< / span > , < span class = "st" > " "< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "difficulties"< / span > , string.< span class = "fn" > Join< / span > (< span class = "st" > ","< / span > , mappedDiffs)), < span class = "ann" > // "Normal,Hard,Expert"< / span >
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "modes"< / span > , < span class = "st" > "Standard"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "events"< / span > , < span class = "st" > "DotBlocks,Obstacles,Bombs"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "environment"< / span > , < span class = "st" > "DefaultEnvironment"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "system_tag"< / span > , < span class = "st" > "v2"< / span > ),
};
< span class = "ann" > // using var = C# 8 선언형 using. 이 변수가 스코프를 벗어나면 자동 Dispose (메모리 해제)< / span >
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Post< / span > (BASE_URL + CREATE_EP, form);
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "Accept"< / span > , < span class = "st" > "*/*"< / span > );
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "User-Agent"< / span > , < span class = "st" > "VRBeatSaber/1.0"< / span > );
< span class = "kw" > yield return< / span > req.< span class = "fn" > SendWebRequest< / span > (); < span class = "ann" > // 요청 완료까지 대기< / span >
< span class = "kw" > if< / span > (req.result != < span class = "ty" > UnityWebRequest< / span > .Result.Success)
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Level create failed: {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // 응답 예: {"id":"abc123def"} → ParseJsonString으로 "abc123def" 추출< / span >
< span class = "ty" > string< / span > levelId = < span class = "fn" > ParseJsonString< / span > (req.downloadHandler.text, < span class = "st" > "id"< / span > );
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (levelId))
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Failed to parse levelId: {req.downloadHandler.text}"< / span > ); < span class = "kw" > yield break< / span > ; }
onSuccess?.< span class = "fn" > Invoke< / span > (levelId);
}
< span class = "ann" > // URL 버전 — 파일 바이트 대신 audio_url 필드에 URL 문자열을 보냄< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > CreateLevelFromUrl< / span > (< span class = "ty" > string< / span > audioUrl, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties,
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onSuccess, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "kw" > var< / span > mappedDiffs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > ();
< span class = "kw" > foreach< / span > (< span class = "ty" > string< / span > d < span class = "kw" > in< / span > difficulties)
< span class = "kw" > if< / span > (DiffNames.< span class = "fn" > TryGetValue< / span > (d, < span class = "kw" > out var< / span > n)) mappedDiffs.< span class = "fn" > Add< / span > (n);
< span class = "kw" > if< / span > (mappedDiffs.Count == < span class = "nm" > 0< / span > ) { onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "No supported difficulties."< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "kw" > var< / span > form = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > IMultipartFormSection< / span > >
{
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "audio_url"< / span > , audioUrl), < span class = "ann" > // 파일 업로드 대신 URL< / span >
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "audio_metadata_title"< / span > , < span class = "st" > " "< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "audio_metadata_artist"< / span > , < span class = "st" > " "< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "difficulties"< / span > , string.< span class = "fn" > Join< / span > (< span class = "st" > ","< / span > , mappedDiffs)),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "modes"< / span > , < span class = "st" > "Standard"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "events"< / span > , < span class = "st" > "DotBlocks,Obstacles,Bombs"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "environment"< / span > , < span class = "st" > "DefaultEnvironment"< / span > ),
< span class = "kw" > new< / span > < span class = "ty" > MultipartFormDataSection< / span > (< span class = "st" > "system_tag"< / span > , < span class = "st" > "v2"< / span > ),
};
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Post< / span > (BASE_URL + CREATE_EP, form);
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "Accept"< / span > , < span class = "st" > "*/*"< / span > ); req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "User-Agent"< / span > , < span class = "st" > "VRBeatSaber/1.0"< / span > );
< 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)
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Level create (URL) failed: {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ty" > string< / span > levelId = < span class = "fn" > ParseJsonString< / span > (req.downloadHandler.text, < span class = "st" > "id"< / span > );
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (levelId))
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Failed to parse levelId: {req.downloadHandler.text}"< / span > ); < span class = "kw" > yield break< / span > ; }
onSuccess?.< span class = "fn" > Invoke< / span > (levelId);
}
< span class = "ann" > // ── Heartbeat: 생성 완료 여부 1회 체크 ──────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > PollHeartbeat< / span > (< span class = "ty" > string< / span > levelId,
< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onStatus, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ann" > // string.Format = {0} 자리에 levelId 삽입< / span >
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (BASE_URL + string.< span class = "fn" > Format< / span > (HEARTBEAT_EP, levelId));
< 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)
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Heartbeat failed: {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // ?? "" = status가 null이면 빈 문자열 (PollAndDownload의 Equals가 null에 안전)< / span >
onStatus?.< span class = "fn" > Invoke< / span > (< span class = "fn" > ParseJsonString< / span > (req.downloadHandler.text, < span class = "st" > "status"< / span > ) ?? < span class = "st" > ""< / span > );
}
< span class = "ann" > // ── ZIP 다운로드: 서버 500 오류 시 최대 3회 재시도 ────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > DownloadZip< / span > (< span class = "ty" > string< / span > levelId,
< span class = "ty" > Action< / span > < < span class = "ty" > byte< / span > []> onSuccess, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ty" > string< / span > url = BASE_URL + string.< span class = "fn" > Format< / span > (DOWNLOAD_EP, levelId);
< span class = "ann" > // 재시도 루프: attempt 1~3< / span >
< span class = "kw" > for< / span > (< span class = "kw" > int< / span > attempt = < span class = "nm" > 1< / span > ; attempt < = < span class = "nm" > 3< / span > ; attempt++)
{
< 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)
{ onSuccess?.< span class = "fn" > Invoke< / span > (req.downloadHandler.data); < span class = "kw" > yield break< / span > ; } < span class = "ann" > // 성공 → byte[] 전달< / span >
< span class = "ann" > // HTTP 500 = 서버 내부 오류. Beat Sage가 처리 중일 때 발생할 수 있어 재시도< / span >
< span class = "kw" > if< / span > (req.responseCode == < span class = "nm" > 500< / span > & & attempt < < span class = "nm" > 3< / span > )
{
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "[3/4] Server error, retrying ({attempt}/3)..."< / span > );
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (< span class = "nm" > 5f< / span > );
< span class = "kw" > continue< / span > ; < span class = "ann" > // 다음 시도로< / span >
}
onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "ZIP download failed: {req.error} (HTTP {req.responseCode})"< / span > );
< span class = "kw" > yield break< / span > ;
}
}
< span class = "ann" > // ── ZIP 해제 + 각 .dat 파일을 NoteData로 변환 ────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > < span class = "fn" > ExtractAndConvert< / span > (
< span class = "kw" > byte< / span > [] zipBytes, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties, < span class = "ty" > float< / span > fallbackBpm)
{
< span class = "kw" > var< / span > result = < span class = "kw" > new< / span > < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > ();
< span class = "ann" > // byte[]를 MemoryStream으로 감싸면 파일 없이 ZipArchive로 다룰 수 있다< / span >
< span class = "kw" > using var< / span > ms = < span class = "kw" > new< / span > < span class = "ty" > MemoryStream< / span > (zipBytes);
< span class = "kw" > using var< / span > archive = < span class = "kw" > new< / span > < span class = "ty" > ZipArchive< / span > (ms, ZipArchiveMode.Read);
< span class = "ann" > // info.dat 먼저 파싱 → BPM 및 메타데이터 획득< / span >
< span class = "kw" > float< / span > bpm = fallbackBpm;
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > e < span class = "kw" > in< / span > archive.Entries)
{
< span class = "kw" > if< / span > (!string.< span class = "fn" > Equals< / span > (e.Name, < span class = "st" > "info.dat"< / span > , StringComparison.OrdinalIgnoreCase)) < span class = "kw" > continue< / span > ;
< span class = "kw" > using var< / span > r = < span class = "kw" > new< / span > < span class = "ty" > StreamReader< / span > (e.< span class = "fn" > Open< / span > (), Encoding.UTF8);
< span class = "kw" > var< / span > meta = < span class = "ty" > BeatSageConverter< / span > .< span class = "fn" > ParseInfoDat< / span > (r.< span class = "fn" > ReadToEnd< / span > ());
< span class = "kw" > if< / span > (meta != < span class = "kw" > null< / span > )
{
LastMetadata = meta; < span class = "ann" > // SongCreatorManager가 이 값으로 UI 자동완성< / span >
< span class = "kw" > if< / span > (meta.bpm > < span class = "nm" > 0< / span > ) bpm = meta.bpm; < span class = "ann" > // info.dat BPM이 있으면 우선 사용< / span >
}
< span class = "kw" > break< / span > ;
}
< span class = "ann" > // 각 난이도 .dat 파일을 찾아 변환< / span >
< span class = "kw" > foreach< / span > (< span class = "ty" > string< / span > diff < span class = "kw" > in< / span > difficulties)
{
< span class = "kw" > if< / span > (!DatFileNames.< span class = "fn" > TryGetValue< / span > (diff, < span class = "kw" > out< / span > < span class = "ty" > string< / span > datName)) < span class = "kw" > continue< / span > ;
< span class = "ty" > ZipArchiveEntry< / span > entry = < span class = "kw" > null< / span > ;
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > e < span class = "kw" > in< / span > archive.Entries)
< span class = "kw" > if< / span > (string.< span class = "fn" > Equals< / span > (e.Name, datName, StringComparison.OrdinalIgnoreCase))
{ entry = e; < span class = "kw" > break< / span > ; }
< span class = "kw" > if< / span > (entry == < span class = "kw" > null< / span > ) { Debug.< span class = "fn" > LogWarning< / span > ($< span class = "st" > "{datName} not found"< / span > ); < span class = "kw" > continue< / span > ; }
< span class = "kw" > using var< / span > reader = < span class = "kw" > new< / span > < span class = "ty" > StreamReader< / span > (entry.< span class = "fn" > Open< / span > (), Encoding.UTF8);
< span class = "ann" > // BeatSageConverter.Convert = .dat JSON → NoteData 리스트 (비트→초 변환 포함)< / span >
result[diff] = < span class = "ty" > BeatSageConverter< / span > .< span class = "fn" > Convert< / span > (reader.< span class = "fn" > ReadToEnd< / span > (), bpm);
}
< span class = "kw" > return< / span > result;
}
< span class = "ann" > // ── 간이 JSON 파서: {"key":"value"} 에서 value만 추출 ────────< / span >
< span class = "ann" > // Newtonsoft.Json 없이 단순 응답을 파싱하기 위한 수동 구현< / span >
< span class = "ann" > // 주의: 값에 이스케이프된 따옴표(\")가 있으면 틀린다 → 단순 응답에만 사용< / span >
< 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 = "st" > "\"{key}\":"< / span > ; < span class = "ann" > // 예: "id":" 를 찾는다< / span >
< span class = "kw" > int< / span > start = json.< span class = "fn" > IndexOf< / span > (search, StringComparison.Ordinal);
< span class = "kw" > if< / span > (start < < span class = "nm" > 0< / span > ) < span class = "kw" > return null< / span > ; < span class = "ann" > // 키 없음< / span >
start += search.Length; < span class = "ann" > // 값 시작 위치로 이동< / span >
< span class = "kw" > int< / span > end = json.< span class = "fn" > IndexOf< / span > (< span class = "st" > '"'< / span > , start); < span class = "ann" > // 닫는 " 찾기< / span >
< span class = "kw" > return< / span > end > start ? json.< span class = "fn" > Substring< / span > (start, end - start) : < span class = "kw" > null< / span > ;
}
< span class = "kw" > private void< / span > < span class = "fn" > SetStatus< / span > (< span class = "ty" > string< / span > msg) => CurrentStatus = msg;
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ NasPublisher.cs ══════════════════════ -->
< div id = "p-naspub" class = "panel" >
< div class = "file-header" >
< h1 > NasPublisher.cs< / h1 >
< p > Synology DSM FileStation API로 NAS에 MP3·맵 JSON을 업로드하고 < code > songs.json< / code > 을 갱신한다.< / p >
< / div >
< div class = "box box-r" > < div class = "lbl" > 왜 수동 multipart?< / div > < p > Unity의 < code > UnityWebRequest.Post(form)< / code > 가 생성하는 multipart boundary 포맷을 DSM이 거부(401)한다. GUID boundary를 직접 만들고 CRLF 줄바꿈을 손으로 조립해야 DSM이 받아들인다.< / p > < / div >
< div class = "cw" > < div class = "ch" > < span > NasPublisher.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > NasPublisher< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // [Header] = Inspector에서 구분선+라벨 표시. 코드에는 영향 없음< / span >
[Header(< span class = "st" > "NAS Connection"< / span > )]
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private string< / span > nasBaseUrl = < span class = "st" > "http://192.168.55.3:5000"< / span > ; < span class = "ann" > // 로컬 NAS IP< / span >
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private string< / span > nasAccount = < span class = "st" > "admin"< / span > ;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private string< / span > nasRootPath = < span class = "st" > "/web/beatsaber"< / span > ;
[Header(< span class = "st" > "Static Server URL"< / span > )]
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private string< / span > staticBaseUrl = < span class = "st" > "http://whdwo798.synology.me/beatsaber"< / span > ;
< span class = "kw" > private string< / span > _sid = < span class = "st" > ""< / span > ; < span class = "ann" > // DSM 로그인 세션 ID. 모든 API 요청에 포함< / span >
< span class = "kw" > private string< / span > _synoToken = < span class = "st" > ""< / span > ; < span class = "ann" > // CSRF 방지 토큰. 업로드 헤더에 추가< / span >
< span class = "kw" > private string< / span > _password = < span class = "st" > ""< / span > ; < span class = "ann" > // nas_config.json에서 읽음. 코드에 하드코딩 금지< / span >
< span class = "ann" > // Awake = Start보다 먼저 실행. 다른 컴포넌트 Start()가 실행되기 전에 설정 완료< / span >
< span class = "kw" > private void< / span > < span class = "fn" > Awake< / span > () => < span class = "fn" > LoadConfig< / span > ();
< span class = "kw" > private void< / span > < span class = "fn" > LoadConfig< / span > ()
{
< span class = "ann" > // StreamingAssets = APK/앱 빌드 후에도 읽기 가능한 에셋 폴더< / span >
< span class = "ann" > // 비밀번호를 코드에 넣지 않고 별도 파일로 관리 (gitignore에 추가됨)< / span >
< span class = "ty" > string< / span > path = Path.< span class = "fn" > Combine< / span > (Application.streamingAssetsPath, < span class = "st" > "nas_config.json"< / span > );
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (path)) { Debug.< span class = "fn" > LogWarning< / span > (< span class = "st" > "nas_config.json not found"< / span > ); < span class = "kw" > return< / span > ; }
< span class = "kw" > var< / span > cfg = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > NasConfig< / span > > (File.< span class = "fn" > ReadAllText< / span > (path));
< span class = "kw" > if< / span > (cfg == < span class = "kw" > null< / span > ) < span class = "kw" > return< / span > ;
_password = cfg.password ?? < span class = "st" > ""< / span > ;
< span class = "ann" > // cfg 값이 있으면 Inspector 기본값을 덮어씀 → 환경별로 config만 바꾸면 됨< / span >
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (cfg.host)) nasBaseUrl = cfg.host;
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (cfg.account)) nasAccount = cfg.account;
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (cfg.rootPath)) nasRootPath = cfg.rootPath;
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
}
< span class = "ann" > // private nested class = NasPublisher 안에서만 쓰는 JSON 역직렬화용 구조체< / span >
[< span class = "ty" > Serializable< / span > ] < span class = "kw" > private class< / span > < span class = "ty" > NasConfig< / span >
{
< span class = "kw" > public string< / span > host; < span class = "kw" > public string< / span > account; < span class = "kw" > public string< / span > rootPath;
< span class = "kw" > public string< / span > staticUrl; < span class = "kw" > public string< / span > password;
}
< span class = "ann" > // ── 메인 업로드 파이프라인 ──────────────────────────────────< / span >
< span class = "kw" > public< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Publish< / span > (
< span class = "ty" > SongInfo< / span > song,
< span class = "ty" > string< / span > audioPath, < span class = "ann" > // null이면 오디오 업로드 스킵 (URL 모드)< / span >
< span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > maps,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress, < span class = "ty" > Action< / span > onComplete, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "ann" > // 로컬 함수 = 이 메서드 안에서만 쓰는 인라인 함수. failed 플래그와 onError를 캡처< / span >
< span class = "kw" > void< / span > < span class = "fn" > OnErr< / span > (< span class = "ty" > string< / span > e) { onError?.< span class = "fn" > Invoke< / span > (e); failed = < span class = "kw" > true< / span > ; }
< span class = "kw" > yield return< / span > < span class = "fn" > Login< / span > (OnErr);
< span class = "ann" > // 로그인 성공 여부는 _sid가 비어있는지로 판단< / span >
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (_sid)) < span class = "kw" > yield break< / span > ;
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.1f< / span > );
< span class = "ann" > // URL 모드(audioPath==null)면 오디오는 이미 NAS에 있으므로 스킵< / span >
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (audioPath))
{
< span class = "ann" > // {nasRootPath}/music 폴더에 {song.id}.mp3 로 업로드< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > UploadFile< / span > (audioPath, $< span class = "st" > "{nasRootPath}/music"< / span > , $< span class = "st" > "{song.id}.mp3"< / span > , OnErr);
< span class = "kw" > if< / span > (failed) { < span class = "kw" > yield return< / span > < span class = "fn" > Logout< / span > (); < span class = "kw" > yield break< / span > ; }
}
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.4f< / span > );
< span class = "kw" > int< / span > total = maps.Count, done = < span class = "nm" > 0< / span > ;
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > kv < span class = "kw" > in< / span > maps) < span class = "ann" > // kv.Key = "normal", kv.Value = List< NoteData> < / span >
{
< span class = "ty" > string< / span > fileName = $< span class = "st" > "Map_{song.id}_{kv.Key}.json"< / span > ; < span class = "ann" > // 예: Map_my_song_hard.json< / span >
< span class = "kw" > byte< / span > [] bytes = Encoding.UTF8.< span class = "fn" > GetBytes< / span > (< span class = "ty" > BeatSageConverter< / span > .< span class = "fn" > ToMapJson< / span > (kv.Value));
< span class = "ann" > // song의 DifficultyInfo.mapFile 필드를 채워줌 → songs.json에 경로가 기록됨< / span >
< span class = "fn" > AssignMapFile< / span > (song, kv.Key, fileName);
< span class = "kw" > yield return< / span > < span class = "fn" > UploadBytes< / span > (bytes, fileName, $< span class = "st" > "{nasRootPath}/maps"< / span > , OnErr);
< span class = "kw" > if< / span > (failed) { < span class = "kw" > yield return< / span > < span class = "fn" > Logout< / span > (); < span class = "kw" > yield break< / span > ; }
done++;
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.4f< / span > + (< span class = "kw" > float< / span > )done / total * < span class = "nm" > 0.3f< / span > );
}
< span class = "kw" > yield return< / span > < span class = "fn" > PatchSongsJson< / span > (song, OnErr); < span class = "ann" > // songs.json 갱신 (upsert)< / span >
< span class = "kw" > if< / span > (failed) { < span class = "kw" > yield return< / span > < span class = "fn" > Logout< / span > (); < span class = "kw" > yield break< / span > ; }
< span class = "kw" > yield return< / span > < span class = "fn" > Logout< / span > ();
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 1f< / span > );
onComplete?.< span class = "fn" > Invoke< / span > ();
}
< span class = "ann" > // ── DSM 로그인: SID + SynoToken 획득 ──────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Login< / span > (< span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ann" > // DSM Auth API: GET 요청으로 파라미터를 쿼리스트링으로 전달< / span >
< span class = "ann" > // EscapeURL = 특수문자를 URL 인코딩 (비밀번호에 @, & 등이 있을 때 필요)< / span >
< span class = "ty" > string< / span > url = $< span class = "st" > "{nasBaseUrl}/webapi/auth.cgi"< / span >
+ $< span class = "st" > "?api=SYNO.API.Auth& version=6& method=login"< / span >
+ $< span class = "st" > "& account={UnityWebRequest.EscapeURL(nasAccount)}"< / span >
+ $< span class = "st" > "& passwd={UnityWebRequest.EscapeURL(_password)}"< / span >
+ $< span class = "st" > "& session=FileStation& format=sid& enable_syno_token=yes"< / span > ;
< 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)
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "DSM login failed: {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ty" > string< / span > resp = req.downloadHandler.text;
_sid = < span class = "fn" > ParseJsonString< / span > (resp, < span class = "st" > "sid"< / span > ); < span class = "ann" > // 세션 ID< / span >
_synoToken = < span class = "fn" > ParseJsonString< / span > (resp, < span class = "st" > "synotoken"< / span > ); < span class = "ann" > // CSRF 토큰 (DSM 7.x 필수)< / span >
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (_sid))
onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "DSM sid parse failed — check credentials."< / span > );
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Logout< / span > ()
{
< span class = "ty" > string< / span > url = $< span class = "st" > "{nasBaseUrl}/webapi/auth.cgi?api=SYNO.API.Auth& version=1& method=logout& session=FileStation& _sid={_sid}"< / span > ;
< 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 > ();
_sid = < span class = "st" > ""< / span > ; < span class = "ann" > // 세션 무효화< / span >
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > UploadFile< / span > (< span class = "ty" > string< / span > localPath, < span class = "ty" > string< / span > nasFolder, < span class = "ty" > string< / span > fileName, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
< span class = "op" > =>< / span > < span class = "fn" > UploadBytes< / span > (File.< span class = "fn" > ReadAllBytes< / span > (localPath), fileName, nasFolder, onError);
< span class = "ann" > // ── 수동 multipart/form-data 바디 조립 ────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > UploadBytes< / span > (< span class = "kw" > byte< / span > [] bytes, < span class = "ty" > string< / span > fileName,
< span class = "ty" > string< / span > nasFolder, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ty" > string< / span > uploadUrl = $< span class = "st" > "{nasBaseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Upload& version=2& method=upload& _sid={UnityWebRequest.EscapeURL(_sid)}"< / span > ;
< span class = "ann" > // GUID("N" 형식) = 하이픈 없는 32자리 랜덤 문자열. boundary로 사용< / span >
< span class = "ann" > // boundary는 바디 안 데이터와 충돌하지 않는 유일한 구분자여야 한다< / span >
< span class = "ty" > string< / span > boundary = Guid.< span class = "fn" > NewGuid< / span > ().< span class = "fn" > ToString< / span > (< span class = "st" > "N"< / span > );
< span class = "kw" > const string< / span > CRLF = < span class = "st" > "\r\n"< / span > ; < span class = "ann" > // HTTP 규격상 줄바꿈은 반드시 CRLF(\r\n)< / span >
< span class = "ann" > // MemoryStream에 multipart 바디를 직접 조립< / span >
< span class = "kw" > using var< / span > body = < span class = "kw" > new< / span > < span class = "ty" > MemoryStream< / span > ();
< span class = "ann" > // 로컬 함수: 문자열을 UTF-8 바이트로 변환해서 스트림에 쓰기< / span >
< span class = "kw" > void< / span > < span class = "fn" > WriteText< / span > (< span class = "ty" > string< / span > s) { < span class = "kw" > var< / span > b = Encoding.UTF8.< span class = "fn" > GetBytes< / span > (s); body.< span class = "fn" > Write< / span > (b, < span class = "nm" > 0< / span > , b.Length); }
< span class = "kw" > void< / span > < span class = "fn" > WriteField< / span > (< span class = "ty" > string< / span > name, < span class = "ty" > string< / span > value)
{
< span class = "fn" > WriteText< / span > ($< span class = "st" > "--{boundary}{CRLF}"< / span > );
< span class = "fn" > WriteText< / span > ($< span class = "st" > "Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}"< / span > );
< span class = "fn" > WriteText< / span > (value + CRLF);
}
< span class = "ann" > // DSM FileStation.Upload API가 요구하는 필드< / span >
< span class = "fn" > WriteField< / span > (< span class = "st" > "path"< / span > , nasFolder); < span class = "ann" > // 업로드 대상 NAS 폴더< / span >
< span class = "fn" > WriteField< / span > (< span class = "st" > "create_parents"< / span > , < span class = "st" > "true"< / span > ); < span class = "ann" > // 폴더 없으면 자동 생성< / span >
< span class = "fn" > WriteField< / span > (< span class = "st" > "overwrite"< / span > , < span class = "st" > "true"< / span > ); < span class = "ann" > // 동일 파일명 덮어쓰기< / span >
< span class = "ann" > // 파일 파트: boundary 뒤에 Content-Disposition + Content-Type + 실제 바이트< / span >
< span class = "fn" > WriteText< / span > ($< span class = "st" > "--{boundary}{CRLF}"< / span > );
< span class = "fn" > WriteText< / span > ($< span class = "st" > "Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}"< / span > );
< span class = "fn" > WriteText< / span > ($< span class = "st" > "Content-Type: application/octet-stream{CRLF}{CRLF}"< / span > );
body.< span class = "fn" > Write< / span > (bytes, < span class = "nm" > 0< / span > , bytes.Length); < span class = "ann" > // 바이너리 데이터 직접 쓰기< / span >
< span class = "fn" > WriteText< / span > (CRLF + $< span class = "st" > "--{boundary}--{CRLF}"< / span > ); < span class = "ann" > // 종료 boundary (뒤에 -- 붙음)< / span >
< span class = "kw" > using var< / span > req = < span class = "kw" > new< / span > < span class = "ty" > UnityWebRequest< / span > (uploadUrl, < span class = "st" > "POST"< / span > );
req.uploadHandler = < span class = "kw" > new< / span > < span class = "ty" > UploadHandlerRaw< / span > (body.< span class = "fn" > ToArray< / span > ()); < span class = "ann" > // 조립한 바디를 raw bytes로< / span >
req.downloadHandler = < span class = "kw" > new< / span > < span class = "ty" > DownloadHandlerBuffer< / span > (); < span class = "ann" > // 응답을 메모리에 받음< / span >
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "Content-Type"< / span > , $< span class = "st" > "multipart/form-data; boundary={boundary}"< / span > );
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (_synoToken))
req.< span class = "fn" > SetRequestHeader< / span > (< span class = "st" > "X-SYNO-TOKEN"< / span > , _synoToken); < span class = "ann" > // DSM 7.x CSRF 토큰< / span >
< 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)
{ onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Upload failed ({fileName}): {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // DSM은 HTTP 200이어도 바디에 "success":false 를 넣어 오류를 알릴 수 있다< / span >
< span class = "kw" > if< / span > (req.downloadHandler.text.< span class = "fn" > Contains< / span > (< span class = "st" > "\"success\":false"< / span > ))
onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "Upload rejected ({fileName}): {req.downloadHandler.text}"< / span > );
}
< span class = "ann" > // ── songs.json 갱신 (upsert) ──────────────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > PatchSongsJson< / span > (< span class = "ty" > SongInfo< / span > newSong, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ty" > SongsList< / span > list = < span class = "kw" > null< / span > ;
< span class = "ann" > // 현재 songs.json 읽기 (실패해도 새로 만들면 되므로 onError 없음)< / span >
< span class = "kw" > using< / span > (< span class = "kw" > var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > ($< span class = "st" > "{staticBaseUrl}/songs.json"< / span > ))
{
< 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)
list = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > SongsList< / span > > (req.downloadHandler.text);
}
< span class = "ann" > // ??= : list가 null이면 새 SongsList 할당 (C# 8 null 병합 할당)< / span >
list ??= < span class = "kw" > new< / span > < span class = "ty" > SongsList< / span > { version = < span class = "st" > "1.0"< / span > , songs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > SongInfo< / span > > () };
< span class = "ann" > // upsert: 같은 id가 있으면 교체, 없으면 추가< / span >
< span class = "ann" > // FindIndex: 조건 람다를 만족하는 첫 번째 인덱스. 없으면 -1< / 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 = "nm" > 0< / span > ) list.songs[idx] = newSong;
< span class = "kw" > else< / span > list.songs.< span class = "fn" > Add< / span > (newSong);
< span class = "ann" > // 갱신된 JSON을 NAS에 다시 업로드< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > UploadBytes< / span > (
Encoding.UTF8.< span class = "fn" > GetBytes< / span > (JsonUtility.< span class = "fn" > ToJson< / span > (list, < span class = "kw" > true< / span > )),
< span class = "st" > "songs.json"< / span > , nasRootPath, onError);
}
< 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 = "st" > "\"{key}\":"< / span > ;
< span class = "kw" > int< / span > start = json.< span class = "fn" > IndexOf< / span > (search, StringComparison.Ordinal);
< span class = "kw" > if< / span > (start < < span class = "nm" > 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 = "st" > '"'< / span > , start);
< span class = "kw" > return< / span > end > start ? json.< span class = "fn" > Substring< / span > (start, end - start) : < span class = "kw" > null< / span > ;
}
< span class = "ann" > // DifficultyInfo.mapFile 필드에 NAS 상대 경로를 채워줌< / span >
< span class = "ann" > // 이 값이 songs.json에 기록되어 나중에 DownloadManager가 다운로드 URL을 조합함< / span >
< span class = "kw" > private static void< / span > < span class = "fn" > AssignMapFile< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > string< / span > diff, < span class = "ty" > string< / span > fileName)
{
< span class = "kw" > var< / span > info = song.difficulties.< span class = "fn" > Get< / span > (diff);
< span class = "kw" > if< / span > (info != < span class = "kw" > null< / span > ) info.mapFile = $< span class = "st" > "maps/{fileName}"< / span > ;
}
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ DownloadManager.cs ══════════════════════ -->
< div id = "p-dlmgr" class = "panel" >
< div class = "file-header" >
< h1 > DownloadManager.cs< / h1 >
< p > NAS 정적 서버에서 MP3·맵 JSON을 로컬 캐시로 내려받는다. 이미 있는 파일은 스킵(멱등성).< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > DownloadManager.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > DownloadManager< / span > : < span class = "ty" > MonoBehaviour< / span >
{
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private string< / span > baseUrl = < span class = "st" > "http://whdwo798.synology.me/beatsaber"< / span > ;
< span class = "ann" > // static 프로퍼티: 인스턴스 없이 클래스 이름으로 접근 가능< / span >
< span class = "ann" > // => 로 한 줄 표현 (expression-bodied property)< / span >
< span class = "kw" > private static string< / span > CacheRoot => Path.< span class = "fn" > Combine< / span > (Application.temporaryCachePath, < span class = "st" > "beatsaber"< / span > );
< span class = "ann" > // ── 공개 API ───────────────────────────────────────────────< / span >
< span class = "ann" > // songs.json 전체 목록 가져오기< / span >
< span class = "kw" > public void< / span > < span class = "fn" > FetchSongsList< / span > (< span class = "ty" > Action< / span > < < span class = "ty" > SongsList< / span > > onSuccess, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError = < span class = "kw" > null< / span > )
{
< span class = "ann" > // StartCoroutine: MonoBehaviour에서 코루틴 시작. void 메서드에서도 비동기 로직 실행 가능< / span >
< span class = "fn" > StartCoroutine< / span > (< span class = "fn" > GetText< / span > ($< span class = "st" > "{baseUrl}/songs.json"< / span > , json => {
< span class = "ty" > SongsList< / span > list = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > SongsList< / span > > (json);
< span class = "kw" > if< / span > (list == < span class = "kw" > null< / span > ) onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "songs.json 파싱 실패"< / span > );
< span class = "kw" > else< / span > onSuccess?.< span class = "fn" > Invoke< / span > (list);
}, onError));
}
< span class = "ann" > // 곡 다운로드 시작 (오디오 + 맵 JSON)< / span >
< span class = "kw" > public void< / span > < span class = "fn" > DownloadSong< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > string< / span > difficulty,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress, < span class = "ty" > Action< / span > onComplete, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError = < span class = "kw" > null< / span > )
=> < span class = "fn" > StartCoroutine< / span > (< span class = "fn" > DownloadSongCoroutine< / span > (song, difficulty, onProgress, onComplete, onError));
< span class = "kw" > public void< / span > < span class = "fn" > DeleteSong< / span > (< span class = "ty" > string< / span > songId)
{
< span class = "ty" > string< / span > dir = < span class = "fn" > SongDir< / span > (songId);
< span class = "kw" > if< / span > (Directory.< span class = "fn" > Exists< / span > (dir))
Directory.< span class = "fn" > Delete< / span > (dir, recursive: < span class = "kw" > true< / span > ); < span class = "ann" > // 폴더 전체 삭제< / span >
}
< span class = "ann" > // 오디오 파일 존재 = 곡이 다운로드됐다는 증거< / span >
< span class = "kw" > public bool< / span > < span class = "fn" > IsSongDownloaded< / span > (< span class = "ty" > string< / span > songId) => File.< span class = "fn" > Exists< / span > (< span class = "fn" > AudioPath< / span > (songId));
< span class = "kw" > public bool< / span > < span class = "fn" > IsDifficultyDownloaded< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > string< / span > difficulty)
{
< span class = "ty" > string< / span > path = < span class = "fn" > MapPath< / span > (song, difficulty);
< span class = "kw" > return< / span > path != < span class = "kw" > null< / span > & & File.< span class = "fn" > Exists< / span > (path);
}
< span class = "kw" > public string< / span > < span class = "fn" > AudioPath< / span > (< span class = "ty" > string< / span > songId) => Path.< span class = "fn" > Combine< / span > (< span class = "fn" > SongDir< / span > (songId), $< span class = "st" > "{songId}.mp3"< / span > );
< span class = "kw" > public string< / span > < span class = "fn" > MapPath< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > string< / span > difficulty)
{
< span class = "ty" > DifficultyInfo< / span > info = song.difficulties.< span class = "fn" > Get< / span > (difficulty);
< span class = "kw" > if< / span > (info == < span class = "kw" > null< / span > || string.< span class = "fn" > IsNullOrEmpty< / span > (info.mapFile)) < span class = "kw" > return null< / span > ;
< span class = "ty" > string< / span > fileName = Path.< span class = "fn" > GetFileName< / span > (info.mapFile); < span class = "ann" > // "maps/Map_id_hard.json" → "Map_id_hard.json"< / span >
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (fileName)) < span class = "kw" > return null< / span > ;
< span class = "kw" > return< / span > Path.< span class = "fn" > Combine< / span > (< span class = "fn" > SongDir< / span > (song.id), fileName);
}
< span class = "ann" > // ── 내부 구현 ──────────────────────────────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > DownloadSongCoroutine< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > string< / span > difficulty,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress, < span class = "ty" > Action< / span > onComplete, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "ann" > // GetFullPath = 상대경로를 절대경로로 정규화. DownloadHandlerFile이 절대경로를 요구< / span >
< span class = "ty" > string< / span > songDir = Path.< span class = "fn" > GetFullPath< / span > (< span class = "fn" > SongDir< / span > (song.id));
Directory.< span class = "fn" > CreateDirectory< / span > (songDir); < span class = "ann" > // 없으면 생성, 있으면 무시 (안전)< / span >
< span class = "ann" > // 1단계: 오디오 (전체 진행률의 0~70%)< / span >
< span class = "ty" > string< / span > audioPath = Path.< span class = "fn" > Combine< / span > (songDir, $< span class = "st" > "{song.id}.mp3"< / span > );
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (audioPath)) < span class = "ann" > // 멱등성: 이미 있으면 스킵< / span >
{
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadFile< / span > (
$< span class = "st" > "{baseUrl}/{song.audioFile}"< / span > , audioPath,
p => onProgress?.< span class = "fn" > Invoke< / span > (p * < span class = "nm" > 0.7f< / span > ), < span class = "ann" > // 0.0~0.7로 스케일< / span >
err => { onError?.< span class = "fn" > Invoke< / span > (err); failed = < span class = "kw" > true< / span > ; });
< span class = "kw" > if< / span > (failed) < span class = "kw" > yield break< / span > ;
}
< span class = "ann" > // 2단계: 맵 JSON (전체 진행률의 70~100%)< / span >
< span class = "ty" > DifficultyInfo< / span > diffInfo = song.difficulties.< span class = "fn" > Get< / span > (difficulty);
< span class = "kw" > if< / span > (diffInfo == < span class = "kw" > null< / span > ) { onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "난이도 '{difficulty}' 없음"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (diffInfo.mapFile))
{ onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "맵 파일 정보 없음 — 곡을 다시 생성해주세요"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ty" > string< / span > mapPath = < span class = "fn" > MapPath< / span > (song, difficulty);
< span class = "kw" > if< / span > (mapPath != < span class = "kw" > null< / span > ) mapPath = Path.< span class = "fn" > GetFullPath< / span > (mapPath);
< span class = "kw" > if< / span > (mapPath == < span class = "kw" > null< / span > ) { onError?.< span class = "fn" > Invoke< / span > (< span class = "st" > "맵 경로 계산 실패"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (mapPath))
{
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "kw" > yield return< / span > < span class = "fn" > DownloadFile< / span > (
$< span class = "st" > "{baseUrl}/{diffInfo.mapFile}"< / span > , mapPath,
p => onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 0.7f< / span > + p * < span class = "nm" > 0.3f< / span > ), < span class = "ann" > // 0.7~1.0으로 스케일< / span >
err => { onError?.< span class = "fn" > Invoke< / span > (err); failed = < span class = "kw" > true< / span > ; });
< span class = "kw" > if< / span > (failed) < span class = "kw" > yield break< / span > ;
}
onProgress?.< span class = "fn" > Invoke< / span > (< span class = "nm" > 1f< / span > );
onComplete?.< span class = "fn" > Invoke< / span > ();
}
< span class = "ann" > // ── 단일 파일 다운로드 ─────────────────────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > DownloadFile< / span > (< span class = "ty" > string< / span > url, < span class = "ty" > string< / span > savePath,
< span class = "ty" > Action< / span > < < span class = "ty" > float< / span > > onProgress, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (url);
< span class = "ann" > // DownloadHandlerFile = 응답 바이트를 메모리에 올리지 않고 바로 파일에 씀 (메모리 절약)< / span >
req.downloadHandler = < span class = "kw" > new< / span > < span class = "ty" > DownloadHandlerFile< / span > (savePath);
req.< span class = "fn" > SendWebRequest< / span > (); < span class = "ann" > // yield return 없이 시작 → 아래 while에서 진행률 폴링< / span >
< span class = "kw" > while< / span > (!req.isDone)
{
onProgress?.< span class = "fn" > Invoke< / span > (req.downloadProgress); < span class = "ann" > // 0.0~1.0 다운로드 진행률< / span >
< span class = "kw" > yield return null< / span > ; < span class = "ann" > // 한 프레임 대기< / span >
}
< span class = "kw" > if< / span > (req.result != < span class = "ty" > UnityWebRequest< / span > .Result.Success)
{
< span class = "ann" > // 실패 시 불완전 파일 삭제 → 다음 시도 때 "캐시 히트"로 잘못 판단하는 걸 방지< / span >
< span class = "kw" > if< / span > (File.< span class = "fn" > Exists< / span > (savePath)) File.< span class = "fn" > Delete< / span > (savePath);
onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "다운로드 실패: {url} — {req.error}"< / span > );
}
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > GetText< / span > (< span class = "ty" > string< / span > url, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onSuccess, < span class = "ty" > Action< / span > < < span class = "ty" > string< / span > > onError)
{
< 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)
onError?.< span class = "fn" > Invoke< / span > ($< span class = "st" > "요청 실패: {url} — {req.error}"< / span > );
< span class = "kw" > else< / span >
onSuccess?.< span class = "fn" > Invoke< / span > (req.downloadHandler.text);
}
< span class = "ann" > // 모든 경로 계산의 기준점. 이 값이 SongController의 CacheRoot와 동일해야 파일을 공유 가능< / span >
< span class = "kw" > private static string< / span > < span class = "fn" > SongDir< / span > (< span class = "ty" > string< / span > songId) => Path.< span class = "fn" > Combine< / span > (CacheRoot, songId);
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ SongController.cs ══════════════════════ -->
< div id = "p-songctrl" class = "panel" >
< div class = "file-header" >
< h1 > SongController.cs< / h1 >
< p > Game 씬의 핵심 브릿지. GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고 카운트다운 후 큐브를 스폰한다.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > SongController.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > SongController< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // [SerializeField] = private이지만 Inspector에서 값 할당 가능< / span >
< span class = "ann" > // Spawneable = VRBeatsKit의 큐브 프리팹 베이스 타입< / span >
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Spawneable< / span > cubePrefab;
< span class = "ann" > // GameEvent = VRBeatsKit의 ScriptableObject 기반 이벤트. 씬 완료 시 발동< / span >
[< 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;
2026-05-26 19:12:06 +09:00
< 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 > ;
2026-05-26 00:18:32 +09:00
< 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 >
< span class = "kw" > private static string< / span > CacheRoot => Path.< span class = "fn" > Combine< / span > (Application.temporaryCachePath, < span class = "st" > "beatsaber"< / span > );
< span class = "kw" > private void< / span > < span class = "fn" > Start< / span > ()
{
2026-05-26 19:12:06 +09:00
< span class = "ann" > // FindFirstObjectByType: Unity 6 기준 FindObjectOfType 대체 API< / span >
_audio = < span class = "fn" > FindFirstObjectByType< / span > < < span class = "ty" > AudioManager< / span > > ();
2026-05-26 00:18:32 +09:00
< span class = "fn" > StartCoroutine< / span > (< span class = "fn" > LoadAndPlay< / span > ());
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > LoadAndPlay< / span > ()
{
< span class = "ty" > SongInfo< / span > song = < span class = "ty" > GameSession< / span > .SelectedSong;
< span class = "ty" > string< / span > diff = < span class = "ty" > GameSession< / span > .SelectedDifficulty;
< span class = "ann" > // Game 씬을 직접 실행하면 null → 에러 후 종료< / span >
< span class = "kw" > if< / span > (song == < span class = "kw" > null< / span > || string.< span class = "fn" > IsNullOrEmpty< / span > (diff))
{ Debug.< span class = "fn" > LogError< / span > (< span class = "st" > "[SongController] No song/difficulty selected"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // ── 오디오 로드 (비동기) ──────────────────────────────────< / span >
< span class = "ty" > string< / span > audioPath = Path.< span class = "fn" > Combine< / span > (CacheRoot, song.id, song.id + < span class = "st" > ".mp3"< / span > );
< span class = "ty" > AudioClip< / span > clip;
< span class = "ann" > // "file://" 프리픽스 = 로컬 파일을 URL 형식으로 접근< / span >
< span class = "ann" > // GetAudioClip은 비동기 → yield return으로 완료까지 대기< / span >
< span class = "kw" > using< / span > (< span class = "kw" > var< / span > req = < span class = "ty" > UnityWebRequestMultimedia< / span > .< span class = "fn" > GetAudioClip< / span > (< span class = "st" > "file://"< / span > + audioPath, < span class = "ty" > AudioType< / span > .MPEG))
{
< 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)
{ Debug.< span class = "fn" > LogError< / span > ($< span class = "st" > "Audio load failed: {req.error}"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // GetContent = 요청에서 AudioClip 추출. using 블록 내에서 호출해야 함< / span >
clip = < span class = "ty" > DownloadHandlerAudioClip< / span > .< span class = "fn" > GetContent< / span > (req);
}
< span class = "ann" > // ── 맵 로드 (동기) ────────────────────────────────────────< / span >
< span class = "ty" > DifficultyInfo< / span > diffInfo = song.difficulties.< span class = "fn" > Get< / span > (diff);
< span class = "kw" > if< / span > (diffInfo == < span class = "kw" > null< / span > ) { Debug.< span class = "fn" > LogError< / span > ($< span class = "st" > "Difficulty '{diff}' not found"< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ty" > string< / span > mapPath = Path.< span class = "fn" > Combine< / span > (CacheRoot, song.id, Path.< span class = "fn" > GetFileName< / span > (diffInfo.mapFile));
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (mapPath)) { Debug.< span class = "fn" > LogError< / span > ($< span class = "st" > "Map missing: {mapPath}"< / span > ); < span class = "kw" > yield break< / span > ; }
< 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 > ; }
2026-05-26 19:12:06 +09:00
< span class = "ann" > // time → position → lineLayer 순 정렬. 같은 시간대 노트도 처리 순서가 안정적이다< / span >
map.target.< span class = "fn" > Sort< / span > (< span class = "fn" > CompareNotes< / span > );
2026-05-26 00:18:32 +09:00
< span class = "ann" > // ── 카운트다운 → 음악 시작 → 스폰 루프 ──────────────────< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > StartCoroutine< / span > (< span class = "fn" > Countdown< / span > ());
_audio.< span class = "fn" > PlayClip< / span > (clip); < span class = "ann" > // 음악 재생 시작< / span >
< span class = "ann" > // SpawnRoutine과 WaitForCompletion을 동시에 시작< / span >
< span class = "ann" > // StartCoroutine() = 코루틴을 "백그라운드"로 실행하고 즉시 반환< / span >
< span class = "ann" > // yield return StartCoroutine() = 완료까지 대기< / span >
< span class = "fn" > StartCoroutine< / span > (< span class = "fn" > SpawnRoutine< / span > (map.target)); < span class = "ann" > // 병렬 실행 (대기 안 함)< / span >
< span class = "kw" > yield return< / span > < span class = "fn" > StartCoroutine< / span > (< span class = "fn" > WaitForCompletion< / span > (clip.length)); < span class = "ann" > // 곡 끝날 때까지 대기< / span >
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Countdown< / span > ()
{
< span class = "kw" > if< / span > (countdownText == < span class = "kw" > null< / span > ) < span class = "kw" > yield break< / span > ;
countdownText.gameObject.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
< span class = "ty" > string< / span > [] labels = { < span class = "st" > "3"< / span > , < span class = "st" > "2"< / span > , < span class = "st" > "1"< / span > , < span class = "st" > "GO!"< / span > };
< span class = "kw" > float< / span > [] durations = { < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.6f< / span > };
< span class = "kw" > for< / span > (< span class = "kw" > int< / span > i = < span class = "nm" > 0< / span > ; i < labels.Length; i++)
{
countdownText.text = labels[i];
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (durations[i]);
}
countdownText.gameObject.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > SpawnRoutine< / span > (< span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > notes)
{
< span class = "kw" > float< / span > travelTime = < span class = "ty" > VR_BeatManager< / span > .instance.GameSettings.TargetTravelTime; < span class = "ann" > // 현재 1.8초< / span >
< span class = "kw" > foreach< / span > (< span class = "ty" > NoteData< / span > note < span class = "kw" > in< / span > notes)
{
< span class = "ann" > // spawnAt = 이 노트를 스폰해야 하는 오디오 시간< / span >
< span class = "ann" > // = 실제 칠 시간 - travelTime (큐브가 날아오는 데 걸리는 시간)< / span >
< span class = "ann" > // Mathf.Max(0f, ...) = 음수 방지. 곡 시작 직후 노트는 즉시 스폰< / span >
< span class = "kw" > float< / span > spawnAt = Mathf.< span class = "fn" > Max< / span > (< span class = "nm" > 0f< / span > , note.time - travelTime);
< span class = "ann" > // WaitUntil: 람다가 true가 될 때까지 매 프레임 체크< / span >
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitUntil< / span > (() => _audio.CurrentTime > = spawnAt);
< span class = "fn" > SpawnNote< / span > (note);
}
}
< span class = "kw" > private void< / span > < span class = "fn" > SpawnNote< / span > (< span class = "ty" > NoteData< / span > note)
{
2026-05-26 19:12:06 +09:00
< 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);
2026-05-26 00:18:32 +09:00
< span class = "ann" > // ★ 핵심: travelTimeOverride 계산< / span >
< span class = "ann" > // 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남< / span >
< span class = "ann" > // 두 노트가 같은 TargetTravelTime(1.8s)으로 스폰되면 히트존 도착이 16ms 어긋남< / span >
< span class = "ann" > // 해결: 스폰 시점의 실제 남은 시간(remaining)을 각 노트의 travelTime으로 사용< / span >
< span class = "ann" > // → 노트A 스폰 시 remaining=1.800, 노트B 스폰 시 remaining=1.784< / span >
< span class = "ann" > // → 각자 다른 speed로 발사되지만 같은 시각에 히트존 도착< / span >
< span class = "kw" > float< / span > remaining = note.time - _audio.CurrentTime;
< span class = "kw" > float< / span > travelTime = Mathf.< span class = "fn" > Max< / span > (< span class = "nm" > 0.05f< / span > , remaining); < span class = "ann" > // 최소 0.05s (0이 되면 즉시 소멸)< / span >
< span class = "kw" > var< / span > info = < span class = "kw" > new< / span > < span class = "ty" > SpawnEventInfo< / span >
{
position = < span class = "kw" > new< / span > < span class = "ty" > Vector3< / span > (x, y, < span class = "nm" > 0f< / span > ),
< span class = "ann" > // colorType 0=빨강=왼손(Left), 1=파랑=오른손(Right)< / span >
colorSide = note.colorType == < span class = "nm" > 0< / span > ? < span class = "ty" > ColorSide< / span > .Left : < span class = "ty" > ColorSide< / span > .Right,
hitDirection = < span class = "fn" > MapCutDirection< / span > (note.cutDirection),
useSpark = < span class = "kw" > true< / span > ,
speed = < span class = "nm" > 2f< / span > ,
travelTimeOverride = travelTime, < span class = "ann" > // VRBeatsKit이 이 값을 TargetTravelTime 대신 사용< / span >
};
< span class = "ty" > VR_BeatManager< / span > .instance.< span class = "fn" > Spawn< / span > (cubePrefab, info);
}
2026-05-26 19:12:06 +09:00
< 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;
}
2026-05-26 00:18:32 +09:00
< 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 >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Direction< / span > [] CutDirMap =
{
< span class = "ty" > Direction< / span > .Up, < span class = "ann" > // 0< / span >
< span class = "ty" > Direction< / span > .Down, < span class = "ann" > // 1< / span >
< span class = "ty" > Direction< / span > .Left, < span class = "ann" > // 2< / span >
< span class = "ty" > Direction< / span > .Right, < span class = "ann" > // 3< / span >
< span class = "ty" > Direction< / span > .UpperLeft, < span class = "ann" > // 4< / span >
< span class = "ty" > Direction< / span > .UpperRight,< span class = "ann" > // 5< / span >
< span class = "ty" > Direction< / span > .LowerLeft, < span class = "ann" > // 6< / span >
< span class = "ty" > Direction< / span > .LowerRight,< span class = "ann" > // 7< / span >
< span class = "ty" > Direction< / span > .Center, < span class = "ann" > // 8 = Any (점 블록)< / span >
};
< span class = "ann" > // 범위 체크 후 매핑. 범위 밖이면 Center(아무 방향)로 안전 처리< / span >
< span class = "kw" > private static< / span > < span class = "ty" > Direction< / span > < span class = "fn" > MapCutDirection< / span > (< span class = "kw" > int< / span > cut)
=> (cut > = < span class = "nm" > 0< / span > & & cut < CutDirMap.Length) ? CutDirMap[cut] : < span class = "ty" > Direction< / span > .Center;
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > WaitForCompletion< / span > (< span class = "kw" > float< / span > clipLength)
{
< span class = "ann" > // 0.5초 여유: 마지막 노트 판정 후 ResultsPanel이 뜨기 전 잠깐 대기< / span >
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitUntil< / span > (() => _audio.CurrentTime > = clipLength - < span class = "nm" > 0.5f< / span > );
< span class = "ann" > // ?. = onLevelComplete가 Inspector에 연결 안 됐으면 아무것도 안 함< / span >
onLevelComplete?.< span class = "fn" > Invoke< / span > (); < span class = "ann" > // VRBeatsKit GameEvent 발동 → ResultsPanel 등 연결된 리스너 실행< / span >
}
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ AudioManager.cs ══════════════════════ -->
< 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 >
2026-05-26 19:12:06 +09:00
< p > VRBeatsKit 내장 오디오 관리자. < code > PlayScheduled()< / code > 와 < code > AudioSettings.dspTime< / code > 기준 시간을 사용해 음악/노트 싱크 흔들림을 줄인다.< / p >
2026-05-26 00:18:32 +09:00
< / 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 >
{
< span class = "ann" > // [RequireComponent] = 이 컴포넌트를 붙이려면 AudioSource도 반드시 있어야 함< / span >
< span class = "ann" > // Inspector에서 AudioSource 없이 추가하려 하면 Unity가 자동으로 같이 추가< / span >
[< span class = "ty" > RequireComponent< / span > (< span class = "kw" > typeof< / span > (< span class = "ty" > AudioSource< / span > ))]
< span class = "kw" > public class< / span > < span class = "ty" > AudioManager< / span > : < span class = "ty" > MonoBehaviour< / span >
{
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > AudioMixerGroup< / span > mixerGroup = < span class = "kw" > null< / span > ;
[< 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 > ;
2026-05-26 19:12:06 +09:00
< 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 > ;
2026-05-26 00:18:32 +09:00
< span class = "kw" > private void< / span > < span class = "fn" > Start< / span > ()
{
< span class = "ann" > // GetComponent: 같은 GameObject의 다른 컴포넌트를 참조< / span >
audioSource = < span class = "fn" > GetComponent< / span > < < span class = "ty" > AudioSource< / span > > ();
< span class = "ann" > // AudioMixerGroup: 여러 AudioSource의 볼륨/이펙트를 믹서에서 일괄 제어< / span >
audioSource.outputAudioMixerGroup = mixerGroup;
< span class = "fn" > ResetThisComponent< / span > ();
}
< span class = "kw" > private void< / span > < span class = "fn" > ResetThisComponent< / span > ()
{
< span class = "fn" > SetAudioMixerPitch< / span > (< span class = "nm" > 1.0f< / span > ); < span class = "ann" > // 피치 초기화< / span >
< span class = "ann" > // CancelAllTweens: PlatinioTween 라이브러리 - 진행 중인 트윈 취소< / span >
gameObject.< span class = "fn" > CancelAllTweens< / span > ();
}
< span class = "ann" > // BlendAudioMixerPitch: 피치를 from → to로 서서히 변환 (슬로우 다운 효과)< / span >
< span class = "ann" > // BaseTween 반환 = 트윈 핸들. 호출부에서 취소하거나 완료 콜백을 추가할 수 있음< / span >
< span class = "kw" > public< / span > < span class = "ty" > BaseTween< / span > < span class = "fn" > BlendAudioMixerPitch< / span > (< span class = "kw" > float< / span > from, < span class = "kw" > float< / span > to)
{
< span class = "ann" > // PlatinioTween.ValueTween: 숫자값을 보간하는 트윈< / span >
< span class = "ann" > // SetEase(EaseOutExpo): 처음엔 빠르고 끝에서 느려지는 곡선< / span >
< span class = "ann" > // SetOnUpdateFloat: 매 프레임 보간된 값을 콜백으로 받음< / span >
< span class = "ann" > // SetOwner: 해당 GameObject가 파괴되면 트윈도 자동 취소< / span >
< span class = "kw" > return< / span > < span class = "ty" > PlatinioTween< / span > .instance.< span class = "fn" > ValueTween< / span > (from, to, fadeOutTime)
.< span class = "fn" > SetEase< / span > (< span class = "ty" > Ease< / span > .EaseOutExpo)
.< span class = "fn" > SetOnUpdateFloat< / span > (< span class = "kw" > delegate< / span > (< span class = "kw" > float< / span > v) {
< span class = "kw" > if< / span > (audioSource != < span class = "kw" > null< / span > ) < span class = "fn" > SetAudioMixerPitch< / span > (v);
})
.< span class = "fn" > SetOwner< / span > (gameObject);
}
< span class = "ann" > // AudioMixer의 "Pitch" 노출 파라미터를 직접 설정< / span >
< span class = "ann" > // AudioMixer에서 Pitch 파라미터를 Exposed Parameters에 등록해야 동작< / span >
< 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" > // SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출< / span >
2026-05-26 19:12:06 +09:00
< span class = "ann" > // 내부적으로 예약 재생을 사용해 프레임 상태와 무관한 DSP 기준 시작 시간을 만든다< / span >
2026-05-26 00:18:32 +09:00
< span class = "kw" > public void< / span > < span class = "fn" > PlayClip< / span > (< span class = "ty" > AudioClip< / span > clip)
{
2026-05-26 19:12:06 +09:00
< span class = "fn" > PlayClipScheduled< / span > (clip);
2026-05-26 00:18:32 +09:00
}
2026-05-26 19:12:06 +09:00
< 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;
}
}
2026-05-26 00:18:32 +09:00
}
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ SongCreatorManager.cs ══════════════════════ -->
< div id = "p-creator" class = "panel" >
< div class = "file-header" >
< h1 > SongCreatorManager.cs< / h1 >
< p > Creator 씬의 UI 관리자. 파일 선택/URL 입력 → Beat Sage → NAS 업로드까지의 전체 플로우를 조율한다.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > SongCreatorManager.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > SongCreatorManager< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // Inspector에서 UI 컴포넌트들을 연결. 각 [Header]는 Inspector에서 구분선 역할< / span >
[Header(< span class = "st" > "Audio Source"< / span > )]
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > TMP_Dropdown< / span > audioDropdown;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Button< / span > refreshBtn;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > TMP_Text< / span > inputPathHint; < span class = "ann" > // MP3 폴더 경로 안내 텍스트< / span >
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > TMP_InputField< / span > urlInput;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Button< / span > urlDownloadBtn;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > TMP_InputField< / span > titleInput, artistInput, bpmInput;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Toggle< / span > toggleNormal, toggleHard, toggleExpert, toggleExpertPlus;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Button< / span > generateButton, backButton;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > GameObject< / span > progressGroup;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > TMP_Text< / span > statusText;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > Slider< / span > progressSlider;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > BeatSageUploader< / span > beatSageUploader;
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > NasPublisher< / span > nasPublisher;
< span class = "ann" > // persistentDataPath = 앱 재시작 후에도 유지되는 폴더 (Documents/게임이름/)< / span >
< span class = "ann" > // temporaryCachePath와 달리 OS가 임의로 삭제하지 않는다< / span >
< span class = "kw" > private static string< / span > InputPath => Path.< span class = "fn" > Combine< / span > (Application.persistentDataPath, < span class = "st" > "input"< / span > );
< span class = "kw" > private readonly< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > audioFiles = < span class = "kw" > new< / span > (); < span class = "ann" > // 드롭다운에 표시할 MP3 경로 목록< / span >
< span class = "kw" > private string< / span > _pendingFilePath; < span class = "ann" > // 파일 다이얼로그(STA 스레드)에서 메인 스레드로 경로 전달용< / span >
< span class = "kw" > private void< / span > < span class = "fn" > Start< / span > ()
{
Directory.< span class = "fn" > CreateDirectory< / span > (InputPath); < span class = "ann" > // 없으면 생성< / span >
< span class = "kw" > if< / span > (inputPathHint != < span class = "kw" > null< / span > ) inputPathHint.text = $< span class = "st" > "Path: {InputPath}"< / span > ;
< span class = "ann" > // ?. = null 조건부 호출. Inspector에서 연결 안 된 버튼도 안전하게 처리< / span >
refreshBtn?.< span class = "ty" > onClick< / span > .< span class = "fn" > AddListener< / span > (< span class = "fn" > RefreshAudioList< / span > );
generateButton?.< span class = "ty" > onClick< / span > .< span class = "fn" > AddListener< / span > (< span class = "fn" > OnGenerateClicked< / span > );
< span class = "ann" > // 람다: 클릭 시 menuSceneName 씬으로 이동< / span >
backButton?.< span class = "ty" > onClick< / span > .< span class = "fn" > AddListener< / span > (() => < span class = "ty" > SceneManager< / span > .< span class = "fn" > LoadScene< / span > (menuSceneName));
urlDownloadBtn?.< span class = "ty" > onClick< / span > .< span class = "fn" > AddListener< / span > (< span class = "fn" > OnUrlDownloadClicked< / span > );
< span class = "kw" > if< / span > (progressGroup != < span class = "kw" > null< / span > ) progressGroup.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
< span class = "fn" > RefreshAudioList< / span > ();
}
< span class = "kw" > private void< / span > < span class = "fn" > Update< / span > ()
{
< span class = "ann" > // _pendingFilePath는 STA 스레드(파일 다이얼로그)에서 설정된다< / span >
< span class = "ann" > // Unity API는 메인 스레드에서만 호출 가능 → Update()에서 처리< / span >
< span class = "kw" > if< / span > (_pendingFilePath != < span class = "kw" > null< / span > )
{ < span class = "fn" > CopyToInput< / span > (_pendingFilePath); _pendingFilePath = < span class = "kw" > null< / span > ; }
}
< span class = "kw" > private void< / span > < span class = "fn" > RefreshAudioList< / span > ()
{
audioFiles.< span class = "fn" > Clear< / span > ();
audioDropdown?.< span class = "fn" > ClearOptions< / span > ();
< span class = "kw" > var< / span > options = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > ();
< span class = "ann" > // InputPath 폴더의 모든 .mp3 파일을 드롭다운에 추가< / span >
< span class = "kw" > foreach< / span > (< span class = "ty" > string< / span > f < span class = "kw" > in< / span > Directory.< span class = "fn" > GetFiles< / span > (InputPath, < span class = "st" > "*.mp3"< / span > ))
{
audioFiles.< span class = "fn" > Add< / span > (f);
options.< span class = "fn" > Add< / span > (Path.< span class = "fn" > GetFileNameWithoutExtension< / span > (f)); < span class = "ann" > // 확장자 없는 파일명만 표시< / span >
}
< span class = "kw" > if< / span > (options.Count == < span class = "nm" > 0< / span > ) options.< span class = "fn" > Add< / span > (< span class = "st" > "-- no .mp3 files --"< / span > );
audioDropdown?.< span class = "fn" > AddOptions< / span > (options);
}
< span class = "kw" > private void< / span > < span class = "fn" > OnGenerateClicked< / span > ()
{
< span class = "ty" > string< / span > directUrl = urlInput != < span class = "kw" > null< / span > ? urlInput.text.< span class = "fn" > Trim< / span > () : < span class = "st" > ""< / span > ;
< span class = "kw" > bool< / span > hasUrl = !string.< span class = "fn" > IsNullOrEmpty< / span > (directUrl);
< span class = "kw" > bool< / span > hasFile = audioFiles.Count > < span class = "nm" > 0< / span > ;
< span class = "kw" > if< / span > (!hasUrl & & !hasFile) { < span class = "fn" > SetStatus< / span > (< span class = "st" > "No audio source."< / span > ); < span class = "kw" > return< / span > ; }
< span class = "ann" > // float.TryParse: 파싱 실패해도 예외 없음. out float = 결과값 수신< / span >
float.< span class = "fn" > TryParse< / span > (bpmInput?.text, < span class = "kw" > out float< / span > bpmHint);
< span class = "ann" > // 현재 4개 난이도 전체를 항상 생성 (Toggle 값은 현재 미사용)< / span >
< span class = "kw" > var< / span > diffs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > { < span class = "st" > "normal"< / span > , < span class = "st" > "hard"< / span > , < span class = "st" > "expert"< / span > , < span class = "st" > "expertplus"< / span > };
< span class = "kw" > if< / span > (hasUrl)
{
< span class = "ann" > // Uri.TryCreate: URL 형식이 올바른지 검증. 잘못된 URL이면 false< / span >
< span class = "kw" > if< / span > (!< span class = "ty" > Uri< / span > .< span class = "fn" > TryCreate< / span > (directUrl, < span class = "ty" > UriKind< / span > .Absolute, < span class = "kw" > out var< / span > uri) ||
(uri.Scheme != < span class = "st" > "http"< / span > & & uri.Scheme != < span class = "st" > "https"< / span > ))
{ < span class = "fn" > SetStatus< / span > (< span class = "st" > "Invalid URL."< / span > ); < span class = "kw" > return< / span > ; }
< span class = "fn" > StartCoroutine< / span > (< span class = "fn" > GenerateFlowFromUrl< / span > (uri.AbsoluteUri, bpmHint, diffs));
}
< span class = "kw" > else< / span >
{
< span class = "ann" > // audioDropdown.value = 현재 선택된 항목의 인덱스< / span >
< span class = "fn" > StartCoroutine< / span > (< span class = "fn" > GenerateFlow< / span > (audioFiles[audioDropdown.value], bpmHint, diffs));
}
}
< span class = "ann" > // ── URL 모드 파이프라인 (오디오 업로드 없음) ──────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > GenerateFlowFromUrl< / span > (< span class = "ty" > string< / span > audioUrl, < span class = "kw" > float< / span > bpm, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > diffs)
{
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > false< / span > ); < span class = "ann" > // 진행 중 버튼 비활성화< / span >
progressGroup.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
< span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > maps = < span class = "kw" > null< / span > ;
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "ann" > // 1~3단계: Beat Sage API (진행률 0~80%)< / span >
< span class = "kw" > yield return< / span > beatSageUploader.< span class = "fn" > UploadFromUrl< / span > (
audioUrl, diffs, bpm,
onProgress: p => {
progressSlider.value = p * < span class = "nm" > 0.8f< / span > ;
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)"< / span > );
},
onComplete: result => maps = result, < span class = "ann" > // 변환된 맵 수신< / span >
onError: err => { < span class = "fn" > SetStatus< / span > ($< span class = "st" > "Error: {err}"< / span > ); failed = < span class = "kw" > true< / span > ; });
< span class = "kw" > if< / span > (failed) { < span class = "fn" > SetInteractable< / span > (< span class = "kw" > true< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ann" > // SongInfo 조립: info.dat 메타데이터 + UI 입력 합산< / span >
< span class = "ty" > SongInfo< / span > song = < span class = "fn" > BuildSongInfo< / span > (audioUrl, bpm, maps);
< span class = "ann" > // 4단계: NAS 업로드 (진행률 80~100%)< / span >
< span class = "ann" > // audioPath=null → NAS에 오디오 업로드 스킵 (URL로 접근 가능하므로)< / span >
< span class = "kw" > yield return< / span > nasPublisher.< span class = "fn" > Publish< / span > (
song, < span class = "kw" > null< / span > , maps,
onProgress: p => {
progressSlider.value = < span class = "nm" > 0.8f< / span > + p * < span class = "nm" > 0.2f< / span > ;
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)"< / span > );
},
onComplete: () => { progressSlider.value = < span class = "nm" > 1f< / span > ; < span class = "fn" > SetStatus< / span > ($< span class = "st" > "Done! '{song.title}' created."< / span > ); },
onError: err => { < span class = "fn" > SetStatus< / span > ($< span class = "st" > "NAS upload failed: {err}"< / span > ); failed = < span class = "kw" > true< / span > ; });
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > true< / span > );
}
< span class = "ann" > // ── 로컬 파일 모드 파이프라인 (오디오도 NAS에 업로드) ─────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > GenerateFlow< / span > (< span class = "ty" > string< / span > audioPath, < span class = "kw" > float< / span > bpm, < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > diffs)
{
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > false< / span > );
progressGroup.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
< span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > maps = < span class = "kw" > null< / span > ;
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "kw" > yield return< / span > beatSageUploader.< span class = "fn" > Upload< / span > (audioPath, diffs, bpm,
onProgress: p => { progressSlider.value = p * < span class = "nm" > 0.8f< / span > ;
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)"< / span > ); },
onComplete: result => maps = result,
onError: err => { < span class = "fn" > SetStatus< / span > ($< span class = "st" > "Error: {err}"< / span > ); failed = < span class = "kw" > true< / span > ; });
< span class = "kw" > if< / span > (failed) { < span class = "fn" > SetInteractable< / span > (< span class = "kw" > true< / span > ); < span class = "kw" > yield break< / span > ; }
< span class = "ty" > SongInfo< / span > song = < span class = "fn" > BuildSongInfo< / span > (audioPath, bpm, maps);
< span class = "ann" > // audioPath를 전달 → NasPublisher가 MP3도 NAS에 업로드< / span >
< span class = "kw" > yield return< / span > nasPublisher.< span class = "fn" > Publish< / span > (song, audioPath, maps,
onProgress: p => { progressSlider.value = < span class = "nm" > 0.8f< / span > + p * < span class = "nm" > 0.2f< / span > ;
< span class = "fn" > SetStatus< / span > ($< span class = "st" > "[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)"< / span > ); },
onComplete: () => { progressSlider.value = < span class = "nm" > 1f< / span > ; < span class = "fn" > SetStatus< / span > ($< span class = "st" > "Done! '{song.title}' created."< / span > ); },
onError: err => { < span class = "fn" > SetStatus< / span > ($< span class = "st" > "NAS upload failed: {err}"< / span > ); failed = < span class = "kw" > true< / span > ; });
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > true< / span > );
}
< span class = "ann" > // ── SongInfo 조립: info.dat 자동감지값 + UI 입력값 합산 ─────< / span >
< span class = "ann" > // 우선순위: UI 입력 > info.dat > fallback(파일명/타임스탬프)< / span >
< span class = "kw" > private< / span > < span class = "ty" > SongInfo< / span > < span class = "fn" > BuildSongInfo< / span > (< span class = "ty" > string< / span > audioPath, < span class = "kw" > float< / span > fallbackBpm,
< span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > List< / span > < < span class = "ty" > NoteData< / span > > > maps)
{
< span class = "kw" > var< / span > meta = beatSageUploader?.LastMetadata; < span class = "ann" > // info.dat에서 읽은 자동 감지값< / span >
< span class = "kw" > string< / span > uiTitle = titleInput?.text.< span class = "fn" > Trim< / span > () ?? < span class = "st" > ""< / span > ;
< span class = "kw" > string< / span > uiArtist = artistInput?.text.< span class = "fn" > Trim< / span > () ?? < span class = "st" > ""< / span > ;
float.< span class = "fn" > TryParse< / span > (bpmInput?.text, < span class = "kw" > out float< / span > uiBpm);
< span class = "ann" > // 3단계 우선순위 선택: UI입력 → info.dat → 빈 문자열< / span >
< span class = "kw" > string< / span > title = !string.< span class = "fn" > IsNullOrEmpty< / span > (uiTitle) ? uiTitle : (meta?.title ?? < span class = "st" > ""< / span > );
< span class = "kw" > string< / span > artist = !string.< span class = "fn" > IsNullOrEmpty< / span > (uiArtist) ? uiArtist : (meta?.artist ?? < span class = "st" > ""< / span > );
< span class = "ann" > // BPM도 3단계: info.dat(자동감지) → UI입력 → fallback< / span >
< span class = "kw" > float< / span > bpm = (meta != < span class = "kw" > null< / span > & & meta.bpm > < span class = "nm" > 0< / span > ) ? meta.bpm : (uiBpm > < span class = "nm" > 0< / span > ? uiBpm : fallbackBpm);
< span class = "ann" > // 제목이 없으면 파일명 → 그것도 없으면 타임스탬프 기반 id< / span >
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (title) & & !string.< span class = "fn" > IsNullOrEmpty< / span > (audioPath))
title = Path.< span class = "fn" > GetFileNameWithoutExtension< / span > (audioPath);
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (title))
title = $< span class = "st" > "song_{DateTime.Now:yyyyMMdd_HHmmss}"< / span > ;
< span class = "ann" > // id = 소문자+언더스코어. 파일 경로/URL에 안전하게 사용하기 위해< / span >
< span class = "kw" > string< / span > id = title.< span class = "fn" > ToLower< / span > ().< span class = "fn" > Replace< / span > (< span class = "st" > " "< / span > , < span class = "st" > "_"< / span > );
< span class = "ann" > // maps에 있는 난이도만 DifficultyMap에 채움< / span >
< span class = "kw" > var< / span > diffMap = < span class = "kw" > new< / span > < span class = "ty" > DifficultyMap< / span > ();
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > kv < span class = "kw" > in< / span > maps)
{
< span class = "kw" > var< / span > info = < span class = "kw" > new< / span > < span class = "ty" > DifficultyInfo< / span > { noteCount = kv.Value.Count };
< span class = "kw" > switch< / span > (kv.Key)
{
< span class = "kw" > case< / span > < span class = "st" > "normal"< / span > : diffMap.normal = info; < span class = "kw" > break< / span > ;
< span class = "kw" > case< / span > < span class = "st" > "hard"< / span > : diffMap.hard = info; < span class = "kw" > break< / span > ;
< span class = "kw" > case< / span > < span class = "st" > "expert"< / span > : diffMap.expert = info; < span class = "kw" > break< / span > ;
< span class = "kw" > case< / span > < span class = "st" > "expertplus"< / span > : diffMap.expertplus = info; < span class = "kw" > break< / span > ;
}
}
< span class = "kw" > return new< / span > < span class = "ty" > SongInfo< / span >
{
id = id, title = title, artist = artist, bpm = bpm,
audioFile = $< span class = "st" > "music/{id}.mp3"< / span > , < span class = "ann" > // NAS 상대경로< / span >
difficulties = diffMap,
addedAt = < span class = "ty" > DateTime< / span > .Now.< span class = "fn" > ToString< / span > (< span class = "st" > "yyyy-MM-dd"< / span > ),
};
}
< span class = "ann" > // ── 파일 선택 다이얼로그 (플랫폼별 분기) ────────────────────< / span >
< span class = "kw" > private void< / span > < span class = "fn" > OnFilePickerClicked< / span > ()
{
< span class = "ann" > // #if = 조건부 컴파일. 해당 플랫폼에서만 코드가 포함됨< / span >
< span class = "ann" > // UNITY_EDITOR = 에디터에서 실행 시< / span >
#< span class = "kw" > if< / span > UNITY_EDITOR
< span class = "ann" > // EditorUtility.OpenFilePanel = 에디터 전용 파일 선택 다이얼로그< / span >
< span class = "ty" > string< / span > path = < span class = "ty" > UnityEditor< / span > .< span class = "ty" > EditorUtility< / span > .< span class = "fn" > OpenFilePanel< / span > (< span class = "st" > "Select audio file"< / span > , < span class = "st" > ""< / span > , < span class = "st" > "mp3"< / span > );
< span class = "kw" > if< / span > (!string.< span class = "fn" > IsNullOrEmpty< / span > (path)) < span class = "fn" > CopyToInput< / span > (path);
#< span class = "kw" > elif< / span > UNITY_STANDALONE_WIN
< span class = "ann" > // Windows Forms 파일 다이얼로그는 STA(Single Thread Apartment) 스레드에서 실행해야 함< / span >
< span class = "ann" > // Unity 메인 스레드는 MTA → 별도 Thread 생성 필요< / span >
< span class = "kw" > var< / span > t = < span class = "kw" > new< / span > < span class = "ty" > Thread< / span > (() => {
< span class = "kw" > var< / span > dlg = < span class = "kw" > new< / span > < span class = "ty" > System.Windows.Forms.OpenFileDialog< / span > { Filter = < span class = "st" > "MP3|*.mp3"< / span > };
< span class = "kw" > if< / span > (dlg.< span class = "fn" > ShowDialog< / span > () == < span class = "ty" > System.Windows.Forms.DialogResult< / span > .OK)
_pendingFilePath = dlg.FileName; < span class = "ann" > // 메인 스레드 Update()가 이 값을 처리< / span >
});
t.< span class = "fn" > SetApartmentState< / span > (< span class = "ty" > ApartmentState< / span > .STA); < span class = "ann" > // Windows Forms 요구사항< / span >
t.< span class = "fn" > Start< / span > ();
#< span class = "kw" > else< / span >
< span class = "ann" > // Quest/Android: 파일 다이얼로그 없음 → ADB로 복사하는 방법 안내< / span >
< span class = "fn" > SetAddStatus< / span > ($< span class = "st" > "Copy file via ADB:\n{InputPath}"< / span > );
#< span class = "kw" > endif< / span >
}
< span class = "ann" > // ── URL에서 직접 MP3 다운로드 ────────────────────────────────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > DownloadFromUrl< / span > (< span class = "ty" > string< / span > url)
{
< span class = "fn" > SetAddStatus< / span > (< span class = "st" > "Downloading..."< / span > );
< span class = "kw" > if< / span > (urlDownloadBtn != < span class = "kw" > null< / span > ) urlDownloadBtn.interactable = < span class = "kw" > false< / span > ;
< span class = "ann" > // URL 경로에서 파일명 추출. 없거나 .mp3가 아니면 "download.mp3"< / span >
< span class = "ty" > string< / span > fileName;
< span class = "kw" > try< / span > { < span class = "ty" > string< / span > uriPath = < span class = "kw" > new< / span > < span class = "ty" > Uri< / span > (url).AbsolutePath;
fileName = Path.< span class = "fn" > GetFileName< / span > (uriPath);
< span class = "kw" > if< / span > (string.< span class = "fn" > IsNullOrEmpty< / span > (fileName) || !fileName.< span class = "fn" > EndsWith< / span > (< span class = "st" > ".mp3"< / span > , StringComparison.OrdinalIgnoreCase))
fileName = < span class = "st" > "download.mp3"< / span > ; }
< span class = "kw" > catch< / span > { fileName = < span class = "st" > "download.mp3"< / span > ; }
< span class = "ty" > string< / span > savePath = Path.< span class = "fn" > GetFullPath< / span > (Path.< span class = "fn" > Combine< / span > (InputPath, fileName));
< span class = "kw" > using var< / span > req = < span class = "ty" > UnityWebRequest< / span > .< span class = "fn" > Get< / span > (url);
req.downloadHandler = < span class = "kw" > new< / span > < span class = "ty" > DownloadHandlerFile< / span > (savePath); < span class = "ann" > // 파일로 직접 저장< / span >
< span class = "kw" > yield return< / span > req.< span class = "fn" > SendWebRequest< / span > ();
< span class = "kw" > if< / span > (urlDownloadBtn != < span class = "kw" > null< / span > ) urlDownloadBtn.interactable = < span class = "kw" > true< / span > ;
< span class = "kw" > if< / span > (req.result == < span class = "ty" > UnityWebRequest< / span > .Result.Success)
{
< span class = "fn" > RefreshAudioList< / span > ();
< span class = "ann" > // 방금 다운받은 파일을 드롭다운에서 자동 선택< / span >
< span class = "kw" > int< / span > idx = audioFiles.< span class = "fn" > FindIndex< / span > (
f => Path.< span class = "fn" > GetFileNameWithoutExtension< / span > (f) == Path.< span class = "fn" > GetFileNameWithoutExtension< / span > (fileName));
< span class = "kw" > if< / span > (idx > = < span class = "nm" > 0< / span > & & audioDropdown != < span class = "kw" > null< / span > ) audioDropdown.value = idx;
< span class = "fn" > SetAddStatus< / span > ($< span class = "st" > "Downloaded: {fileName}"< / span > );
}
< span class = "kw" > else< / span >
{
< span class = "kw" > if< / span > (File.< span class = "fn" > Exists< / span > (savePath)) File.< span class = "fn" > Delete< / span > (savePath); < span class = "ann" > // 실패 시 불완전 파일 삭제< / span >
< span class = "fn" > SetAddStatus< / span > ($< span class = "st" > "Download failed: {req.error}"< / span > );
}
}
< span class = "kw" > private void< / span > < span class = "fn" > SetInteractable< / span > (< span class = "kw" > bool< / span > value)
{
< span class = "ann" > // 진행 중 모든 입력 비활성화 → 완료 후 다시 활성화< / span >
< span class = "kw" > if< / span > (generateButton != < span class = "kw" > null< / span > ) generateButton.interactable = value;
< span class = "kw" > if< / span > (audioDropdown != < span class = "kw" > null< / span > ) audioDropdown.interactable = value;
< span class = "kw" > if< / span > (refreshBtn != < span class = "kw" > null< / span > ) refreshBtn.interactable = value;
< span class = "kw" > if< / span > (urlDownloadBtn != < span class = "kw" > null< / span > ) urlDownloadBtn.interactable = value;
}
< span class = "kw" > private void< / span > < span class = "fn" > SetStatus< / span > (< span class = "ty" > string< / span > msg) { < span class = "kw" > if< / span > (statusText != < span class = "kw" > null< / span > ) statusText.text = msg; }
< span class = "kw" > private void< / span > < span class = "fn" > SetAddStatus< / span > (< span class = "ty" > string< / span > msg) { < span class = "kw" > if< / span > (addStatusText != < span class = "kw" > null< / span > ) addStatusText.text = msg; }
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ SongDetailPanel.cs ══════════════════════ -->
< div id = "p-detail" class = "panel" >
< div class = "file-header" >
< h1 > SongDetailPanel.cs< / h1 >
< p > 곡 카드 클릭 시 열리는 상세 패널. 난이도 선택 → 다운로드 → 플레이까지 처리한다.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > SongDetailPanel.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > SongDetailPanel< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // diffSlots: 난이도 4개의 (키, 버튼접근자) 쌍을 배열로 관리< / span >
< span class = "ann" > // Func< SongDetailPanel, Button> = 이 패널을 받아서 해당 버튼을 반환하는 함수< / span >
< span class = "ann" > // 이렇게 하면 foreach 하나로 4개 난이도를 동일하게 처리 가능 (코드 중복 제거)< / span >
< span class = "kw" > private readonly< / span > (< span class = "ty" > string< / span > key, < span class = "ty" > Func< / span > < < span class = "ty" > SongDetailPanel< / span > , < span class = "ty" > Button< / span > > btn)[] diffSlots =
{
(< span class = "st" > "normal"< / span > , p => p.btnNormal),
(< span class = "st" > "hard"< / span > , p => p.btnHard),
(< span class = "st" > "expert"< / span > , p => p.btnExpert),
(< span class = "st" > "expertplus"< / span > , p => p.btnExpertPlus),
};
< span class = "ann" > // ── 외부에서 호출하는 진입점 ──────────────────────────────< / span >
< span class = "kw" > public void< / span > < span class = "fn" > Show< / span > (< span class = "ty" > SongInfo< / span > song, < span class = "ty" > DownloadManager< / span > dm, < span class = "ty" > SongSelectManager< / span > sm)
{
currentSong = song; downloadManager = dm; selectManager = sm;
selectedDifficulty = < span class = "kw" > null< / span > ; < span class = "ann" > // 새 곡을 열면 난이도 선택 초기화< / span >
titleText.text = song.title;
artistText.text = song.artist;
< span class = "ann" > // duration이 있으면 "BPM 120 | 3:45" 형식, 없으면 BPM만< / span >
infoText.text = song.duration > < span class = "nm" > 0< / span >
? $< span class = "st" > "BPM {Mathf.RoundToInt(song.bpm)} | {FormatDuration(song.duration)}"< / span >
: $< span class = "st" > "BPM {Mathf.RoundToInt(song.bpm)}"< / span > ;
< span class = "fn" > RefreshUI< / span > ();
}
< span class = "kw" > private void< / span > < span class = "fn" > RefreshUI< / span > ()
{
< span class = "kw" > bool< / span > downloaded = < span class = "ty" > SongLibrary< / span > .Instance.< span class = "fn" > IsSongDownloaded< / span > (currentSong.id);
< span class = "ann" > // 튜플 분해: (key, getBtn) = diffSlots의 각 원소< / span >
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > (key, getBtn) < span class = "kw" > in< / span > diffSlots)
{
< span class = "ty" > Button< / span > btn = getBtn(< span class = "kw" > this< / span > );
< span class = "kw" > bool< / span > exists = currentSong.difficulties.< span class = "fn" > Get< / span > (key) != < span class = "kw" > null< / span > ;
< span class = "ann" > // 다운로드됐고 이 난이도가 존재할 때만 활성화< / span >
btn.interactable = downloaded & & exists;
btn.onClick.< span class = "fn" > RemoveAllListeners< / span > (); < span class = "ann" > // 이전 곡의 리스너 제거 (메모리 누수 방지)< / span >
< span class = "kw" > if< / span > (downloaded & & exists)
{
< span class = "ann" > // string captured = key: foreach 클로저 버그 방지 (각 람다가 자신의 key를 캡처)< / span >
< span class = "ty" > string< / span > captured = key;
btn.onClick.< span class = "fn" > AddListener< / span > (() => < span class = "fn" > SelectDifficulty< / span > (captured));
}
}
< span class = "fn" > UpdateDiffColors< / span > ();
downloadButton.gameObject.< span class = "fn" > SetActive< / span > (!downloaded); < span class = "ann" > // 미다운로드 시 다운로드 버튼< / span >
deleteButton.gameObject.< span class = "fn" > SetActive< / span > (downloaded); < span class = "ann" > // 다운로드 완료 시 삭제 버튼< / span >
playButton.interactable = downloaded & & selectedDifficulty != < span class = "kw" > null< / span > ;
< span class = "ann" > // 리스너를 항상 초기화 후 재등록 → 이전 곡의 리스너가 남는 버그 방지< / span >
downloadButton.onClick.< span class = "fn" > RemoveAllListeners< / span > (); downloadButton.onClick.< span class = "fn" > AddListener< / span > (< span class = "fn" > OnDownloadClicked< / span > );
deleteButton.onClick.< span class = "fn" > RemoveAllListeners< / span > (); deleteButton.onClick.< span class = "fn" > AddListener< / span > (< span class = "fn" > OnDeleteClicked< / span > );
playButton.onClick.< span class = "fn" > RemoveAllListeners< / span > (); playButton.onClick.< span class = "fn" > AddListener< / span > (< span class = "fn" > OnPlayClicked< / span > );
closeButton?.onClick.< span class = "fn" > RemoveAllListeners< / span > ();
closeButton?.onClick.< span class = "fn" > AddListener< / span > (() => gameObject.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > ));
}
< span class = "kw" > private void< / span > < span class = "fn" > UpdateDiffColors< / span > ()
{
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > (key, getBtn) < span class = "kw" > in< / span > diffSlots)
{
< span class = "ty" > Button< / span > btn = getBtn(< span class = "kw" > this< / span > );
< span class = "kw" > bool< / span > selected = key == selectedDifficulty;
< span class = "ann" > // targetGraphic is Image = 타입 패턴 매칭. 캐스트 성공 시 img에 저장< / span >
< span class = "kw" > if< / span > (btn.targetGraphic < span class = "kw" > is< / span > < span class = "ty" > Image< / span > img)
img.color = selected ? SelectedColor : DeselectedImgColor;
}
}
< span class = "ann" > // ── 다운로드: 존재하는 모든 난이도를 순서대로 다운로드 ──────< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > DownloadAllCoroutine< / span > ()
{
< span class = "ann" > // 이 곡에 존재하는 난이도 목록 수집< / span >
< span class = "kw" > var< / span > diffs = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > ();
< span class = "kw" > foreach< / span > (< span class = "kw" > var< / span > (key, _) < span class = "kw" > in< / span > diffSlots)
< span class = "kw" > if< / span > (currentSong.difficulties.< span class = "fn" > Get< / span > (key) != < span class = "kw" > null< / span > ) diffs.< span class = "fn" > Add< / span > (key);
< span class = "kw" > if< / span > (diffs.Count == < span class = "nm" > 0< / span > ) < span class = "kw" > yield break< / span > ;
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > false< / span > );
progressGroup.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
< span class = "kw" > int< / span > totalSteps = diffs.Count;
< span class = "kw" > int< / span > doneSteps = < span class = "nm" > 0< / span > ;
< span class = "kw" > bool< / span > failed = < span class = "kw" > false< / span > ;
< span class = "ann" > // 난이도 하나씩 순서대로 다운로드 (병렬 X)< / span >
< span class = "kw" > foreach< / span > (< span class = "ty" > string< / span > diff < span class = "kw" > in< / span > diffs)
{
< span class = "kw" > bool< / span > stepDone = < span class = "kw" > false< / span > ;
downloadManager.< span class = "fn" > DownloadSong< / span > (
currentSong, diff,
onProgress: p => {
< span class = "ann" > // 전체 진행률 = (완료된 단계 + 현재 단계 진행률) / 총 단계 수< / span >
< span class = "kw" > float< / span > overall = (doneSteps + p) / totalSteps;
progressSlider.value = overall;
progressText.text = $< span class = "st" > "{diffs[Mathf.Min(doneSteps, diffs.Count-1)].ToUpper()} {(int)(overall*100)}%"< / span > ;
},
onComplete: () => {
< span class = "ty" > SongLibrary< / span > .Instance.< span class = "fn" > MarkDownloaded< / span > (currentSong.id, diff); < span class = "ann" > // 라이브러리에 기록< / span >
doneSteps++;
stepDone = < span class = "kw" > true< / span > ;
},
onError: err => { Debug.< span class = "fn" > LogError< / span > (err); failed = < span class = "kw" > true< / span > ; stepDone = < span class = "kw" > true< / span > ; });
< span class = "ann" > // stepDone이 true가 될 때까지 대기 (콜백 기반 → 코루틴 완료 감지)< / span >
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitUntil< / span > (() => stepDone);
< span class = "kw" > if< / span > (failed) < span class = "kw" > break< / span > ;
}
< span class = "fn" > SetInteractable< / span > (< span class = "kw" > true< / span > );
progressGroup.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
selectManager.< span class = "fn" > RefreshCards< / span > (); < span class = "ann" > // 카드 목록에 OWNED 배지 반영< / span >
< span class = "fn" > RefreshUI< / span > ();
}
< span class = "ann" > // ── 플레이: GameSession에 선택 저장 후 씬 이동 ──────────────< / span >
< span class = "kw" > private void< / span > < span class = "fn" > OnPlayClicked< / span > ()
{
< span class = "ty" > GameSession< / span > .SelectedSong = currentSong;
< span class = "ty" > GameSession< / span > .SelectedDifficulty = selectedDifficulty;
< span class = "ty" > SceneManager< / span > .< span class = "fn" > LoadScene< / span > (gameSceneName); < span class = "ann" > // "Game" 씬으로 이동< / span >
}
< span class = "ann" > // "3:45" 형식 포맷터. D2 = 최소 2자리, 부족하면 앞에 0 채움 (45 → "45", 5 → "05")< / span >
< span class = "kw" > private static string< / span > < span class = "fn" > FormatDuration< / span > (< span class = "kw" > int< / span > seconds)
=> $< span class = "st" > "{seconds / 60}:{seconds % 60:D2}"< / span > ;
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ SongSelectManager.cs ══════════════════════ -->
< div id = "p-selectmgr" class = "panel" >
< div class = "file-header" >
< h1 > SongSelectManager.cs< / h1 >
< p > NAS에서 곡 목록을 불러오고 코드로 카드 UI를 동적 생성한다. 오프라인 캐시 폴백 포함.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > SongSelectManager.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > SongSelectManager< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // 탭 색상: 활성=불투명(0.45), 비활성=반투명(0.12)< / span >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Color< / span > TabActive = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.45f< / span > );
< span class = "kw" > private static readonly< / span > < span class = "ty" > Color< / span > TabInactive = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.12f< / span > );
< span class = "ann" > // persistentDataPath: 앱 재시작 후에도 살아남는 폴더. songs_cache.json 저장에 사용< / span >
< span class = "kw" > private static string< / span > CachePath => Path.< span class = "fn" > Combine< / span > (Application.persistentDataPath, < span class = "st" > "songs_cache.json"< / span > );
< span class = "kw" > private void< / span > < span class = "fn" > Start< / span > ()
{
< span class = "ann" > // Resources.Load: Resources 폴더에서 에셋 로드. 없으면 null< / span >
_cardFont = Resources.< span class = "fn" > Load< / span > < < span class = "ty" > TMP_FontAsset< / span > > (< span class = "st" > "Fonts & Materials/NanumGothic SDF"< / span > );
< span class = "kw" > if< / span > (_cardFont == < span class = "kw" > null< / span > ) < span class = "ann" > // 폴백: 한글 폰트 없으면 기본 폰트< / span >
_cardFont = Resources.< span class = "fn" > Load< / span > < < span class = "ty" > TMP_FontAsset< / span > > (< span class = "st" > "Fonts & Materials/LiberationSans SDF"< / span > );
tabAllBtn .onClick.< span class = "fn" > AddListener< / span > (() => < span class = "fn" > SwitchTab< / span > (< span class = "kw" > false< / span > ));
tabOwnedBtn.onClick.< span class = "fn" > AddListener< / span > (() => < span class = "fn" > SwitchTab< / span > (< span class = "kw" > true< / span > ));
detailPanel.gameObject.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
< span class = "fn" > SetTabVisual< / span > (< span class = "kw" > false< / span > );
< span class = "fn" > FetchSongs< / span > ();
}
< span class = "ann" > // ── 서버에서 목록 가져오기 (오프라인 폴백 포함) ─────────────< / span >
< span class = "kw" > private void< / span > < span class = "fn" > FetchSongs< / span > ()
{
loadingOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
errorOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
downloadManager.< span class = "fn" > FetchSongsList< / span > (
onSuccess: list => {
allSongs = list.songs;
< span class = "fn" > SaveCache< / span > (list); < span class = "ann" > // 성공 시 로컬에 캐시 저장< / span >
< span class = "ann" > // 파일시스템 검증: SongLibrary가 알고 있는 다운로드 상태를 실제 파일 존재와 비교< / span >
< span class = "ty" > SongLibrary< / span > .Instance.< span class = "fn" > ValidateWithFileSystem< / span > (downloadManager, list.songs);
loadingOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
< span class = "fn" > RefreshCards< / span > ();
},
onError: _ => {
< span class = "ann" > // 네트워크 실패 → 캐시가 있으면 오프라인 목록 표시< / span >
< span class = "ty" > SongsList< / span > cached = < span class = "fn" > LoadCache< / span > ();
loadingOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > false< / span > );
< span class = "kw" > if< / span > (cached != < span class = "kw" > null< / span > ) { allSongs = cached.songs; < span class = "fn" > RefreshCards< / span > (); }
< span class = "kw" > else< / span > { errorOverlay.< span class = "fn" > SetActive< / span > (< span class = "kw" > true< / span > );
errorText.text = < span class = "st" > "Failed to connect to server\nPlease check your internet connection"< / span > ; }
});
}
< span class = "kw" > public void< / span > < span class = "fn" > RefreshCards< / span > ()
{
< span class = "ann" > // DestroyImmediate: 기존 카드 즉시 삭제. Destroy()는 프레임 끝에 삭제 → 레이아웃 계산 오류 발생< / span >
< span class = "kw" > for< / span > (< span class = "kw" > int< / span > i = cardContainer.childCount - < span class = "nm" > 1< / span > ; i > = < span class = "nm" > 0< / span > ; i--)
< span class = "ty" > DestroyImmediate< / span > (cardContainer.< span class = "fn" > GetChild< / span > (i).gameObject);
< span class = "ann" > // showingOwned=true면 다운로드된 곡만 필터링< / span >
< span class = "ty" > List< / span > < < span class = "ty" > SongInfo< / span > > songs = showingOwned
? allSongs.< span class = "fn" > FindAll< / span > (s => < span class = "ty" > SongLibrary< / span > .Instance.< span class = "fn" > IsSongDownloaded< / span > (s.id))
: allSongs;
< span class = "kw" > foreach< / span > (< span class = "ty" > SongInfo< / span > song < span class = "kw" > in< / span > songs) < span class = "fn" > SpawnCard< / span > (song);
< span class = "ann" > // 레이아웃 재계산 순서가 중요: ForceRebuild → ForceUpdateCanvases< / span >
< span class = "ann" > // 순서가 바뀌면 카드 크기 계산이 틀려서 겹침 현상 발생< / span >
< span class = "ty" > LayoutRebuilder< / span > .< span class = "fn" > ForceRebuildLayoutImmediate< / span > (cardContainer);
< span class = "ty" > Canvas< / span > .< span class = "fn" > ForceUpdateCanvases< / span > ();
}
< span class = "ann" > // ── 카드 1개 동적 생성 ────────────────────────────────────< / span >
< span class = "kw" > private void< / span > < span class = "fn" > SpawnCard< / span > (< span class = "ty" > SongInfo< / span > song)
{
< span class = "kw" > bool< / span > downloaded = < span class = "ty" > SongLibrary< / span > .Instance.< span class = "fn" > IsSongDownloaded< / span > (song.id);
< span class = "ann" > // new GameObject + AddComponent: 프리팹 없이 코드로 UI 생성< / span >
< 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, < span class = "kw" > false< / span > ); < span class = "ann" > // false = 로컬 트랜스폼 유지< / span >
< span class = "ann" > // LayoutElement: VerticalLayoutGroup이 이 카드의 높이/너비를 어떻게 배분할지 제어< / span >
< span class = "ann" > // preferredHeight=13f → 13 units 고정 높이. flexibleWidth=1 → 가로는 꽉 채움< / span >
< span class = "kw" > var< / span > le = card.< span class = "fn" > AddComponent< / span > < < span class = "ty" > LayoutElement< / span > > ();
le.preferredHeight = < span class = "nm" > 13f< / span > ; le.flexibleWidth = < span class = "nm" > 1f< / span > ;
< span class = "kw" > var< / span > bg = card.< span class = "fn" > AddComponent< / span > < < span class = "ty" > Image< / span > > ();
bg.color = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.06f< / span > ); < span class = "ann" > // 반투명 흰색 배경< / span >
< span class = "kw" > var< / span > btn = card.< span class = "fn" > AddComponent< / span > < < span class = "ty" > Button< / span > > ();
btn.targetGraphic = bg;
< span class = "kw" > var< / span > bc = btn.colors;
bc.normalColor = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.06f< / span > );
bc.highlightedColor = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 0.4f< / span > , < span class = "nm" > 0.75f< / span > , < span class = "nm" > 1f< / span > , < span class = "nm" > 0.25f< / span > ); < span class = "ann" > // 호버 시 파란 하이라이트< / span >
bc.pressedColor = < span class = "kw" > new< / span > < span class = "ty" > Color< / span > (< span class = "nm" > 0.3f< / span > , < span class = "nm" > 0.6f< / span > , < span class = "nm" > 0.9f< / span > , < span class = "nm" > 0.45f< / span > );
bc.fadeDuration = < span class = "nm" > 0.1f< / span > ; < span class = "ann" > // 색상 전환 속도< / span >
btn.colors = bc;
< span class = "ann" > // 마퀴 구조: RectMask2D 안에서 텍스트가 스크롤< / span >
< span class = "ann" > // TitleMask(RectMask2D) → Title(TMP_Text + MarqueeText)< / span >
< span class = "kw" > var< / span > titleMask = < span class = "kw" > new< / span > < span class = "ty" > GameObject< / span > (< span class = "st" > "TitleMask"< / span > );
titleMask.transform.< span class = "fn" > SetParent< / span > (card.transform, < span class = "kw" > false< / span > );
< span class = "kw" > var< / span > tmr = titleMask.< span class = "fn" > AddComponent< / span > < < span class = "ty" > RectTransform< / span > > ();
< span class = "ann" > // anchorMin/Max: 0~1 범위로 부모 대비 위치 지정. (0,0.5)~(1,1) = 상반부 전체< / span >
tmr.anchorMin = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 0f< / span > , < span class = "nm" > 0.5f< / span > ); tmr.anchorMax = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 1f< / span > , < span class = "nm" > 1f< / span > );
< span class = "ann" > // offsetMin/Max: anchor 기준 픽셀 오프셋. downloaded면 배지 공간(-20f) 확보< / span >
tmr.offsetMin = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 5f< / span > , < span class = "nm" > 0f< / span > ); tmr.offsetMax = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (downloaded ? < span class = "nm" > -20f< / span > : < span class = "nm" > -3f< / span > , < span class = "nm" > 0f< / span > );
titleMask.< span class = "fn" > AddComponent< / span > < < span class = "ty" > RectMask2D< / span > > (); < span class = "ann" > // 이 컨테이너 영역 밖을 잘라냄< / span >
< span class = "kw" > var< / span > titleGO = < span class = "kw" > new< / span > < span class = "ty" > GameObject< / span > (< span class = "st" > "Title"< / span > );
titleGO.transform.< span class = "fn" > SetParent< / span > (titleMask.transform, < span class = "kw" > false< / span > );
< span class = "kw" > var< / span > tr = titleGO.< span class = "fn" > AddComponent< / span > < < span class = "ty" > RectTransform< / span > > ();
< span class = "ann" > // pivot=(0,0.5): 텍스트 왼쪽 중앙을 기준점으로. MarqueeText가 x를 음수로 이동시켜 스크롤< / span >
tr.anchorMin = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 0f< / span > ,< span class = "nm" > 0f< / span > ); tr.anchorMax = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 0f< / span > ,< span class = "nm" > 1f< / span > ); tr.pivot = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 0f< / span > ,< span class = "nm" > 0.5f< / span > );
tr.anchoredPosition = < span class = "ty" > Vector2< / span > .zero; tr.sizeDelta = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (< span class = "nm" > 500f< / span > , < span class = "nm" > 0f< / span > ); < span class = "ann" > // 넓게 설정 → 마스크 밖으로 나옴< / span >
< span class = "kw" > var< / span > tTmp = titleGO.< span class = "fn" > AddComponent< / span > < < span class = "ty" > TextMeshProUGUI< / span > > ();
< 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 >
2026-05-26 19:12:06 +09:00
tTmp.textWrappingMode = < span class = "ty" > TextWrappingModes< / span > .NoWrap; < span class = "ann" > // Unity 6/TMP 최신 API. 줄바꿈 금지< / span >
2026-05-26 00:18:32 +09:00
titleGO.< span class = "fn" > AddComponent< / span > < < span class = "ty" > MarqueeText< / span > > (); < span class = "ann" > // 텍스트가 컨테이너보다 길면 자동 스크롤< / span >
< span class = "ann" > // ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사< / span >
< span class = "ty" > SongInfo< / span > captured = song;
btn.onClick.< span class = "fn" > AddListener< / span > (() => < span class = "fn" > OnCardClicked< / span > (captured));
}
< span class = "ann" > // ── 캐시 저장/로드 ───────────────────────────────────────< / span >
< span class = "kw" > private static void< / span > < span class = "fn" > SaveCache< / span > (< span class = "ty" > SongsList< / span > list)
{
< span class = "ann" > // 빈 try-catch: 파일 쓰기 실패(권한 등)해도 앱 크래시 방지< / span >
< span class = "kw" > try< / span > { File.< span class = "fn" > WriteAllText< / span > (CachePath, JsonUtility.< span class = "fn" > ToJson< / span > (list, < span class = "kw" > true< / span > )); } < span class = "kw" > catch< / span > { }
}
< span class = "kw" > private static< / span > < span class = "ty" > SongsList< / span > < span class = "fn" > LoadCache< / span > ()
{
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (CachePath)) < span class = "kw" > return null< / span > ;
< span class = "kw" > try< / span > { < span class = "kw" > return< / span > JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > SongsList< / span > > (File.< span class = "fn" > ReadAllText< / span > (CachePath)); } < span class = "kw" > catch< / span > { < span class = "kw" > return null< / span > ; }
}
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ SongLibrary.cs ══════════════════════ -->
< div id = "p-songlibrary" class = "panel" >
< div class = "file-header" >
< h1 > SongLibrary.cs< / h1 >
< p > 다운로드 상태를 < code > song_library.json< / code > 에 영구 저장하는 싱글턴. 파일시스템과 동기화 기능 포함.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > SongLibrary.cs< / span > < / div > < pre >
< span class = "ann" > // 싱글턴(Singleton) 패턴: 앱 전체에서 인스턴스 1개만 존재를 보장< / span >
< span class = "kw" > public class< / span > < span class = "ty" > SongLibrary< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // { get; private set; } = 외부 읽기 가능, 내부만 쓰기 가능< / span >
< span class = "kw" > public static< / span > < span class = "ty" > SongLibrary< / span > Instance { < span class = "kw" > get< / span > ; < span class = "kw" > private set< / span > ; }
< span class = "kw" > private const string< / span > FileName = < span class = "st" > "song_library.json"< / span > ;
< span class = "kw" > private static string< / span > SavePath => Path.< span class = "fn" > Combine< / span > (Application.persistentDataPath, FileName);
< span class = "kw" > private< / span > < span class = "ty" > LibraryData< / span > _data = < span class = "kw" > new< / span > < span class = "ty" > LibraryData< / span > (); < span class = "ann" > // 메모리 내 상태< / span >
< span class = "kw" > private void< / span > < span class = "fn" > Awake< / span > ()
{
< span class = "ann" > // 이미 Instance가 있으면(씬 재로드 등) 이 객체 파괴 → 1개 보장< / span >
< span class = "kw" > if< / span > (Instance != < span class = "kw" > null< / span > ) { < span class = "fn" > Destroy< / span > (gameObject); < span class = "kw" > return< / span > ; }
Instance = < span class = "kw" > this< / span > ;
< span class = "ann" > // DontDestroyOnLoad: 씬 전환 시 파괴되지 않음. 앱 수명 동안 유지< / span >
< span class = "ty" > DontDestroyOnLoad< / span > (gameObject);
< span class = "fn" > Load< / span > (); < span class = "ann" > // 앱 시작 시 저장된 데이터 로드< / span >
}
< span class = "ann" > // MarkDownloaded: 다운로드 완료 후 라이브러리에 기록< / span >
< span class = "kw" > public void< / span > < span class = "fn" > MarkDownloaded< / span > (< span class = "ty" > string< / span > songId, < span class = "ty" > string< / span > difficulty)
{
< span class = "ty" > LibraryEntry< / span > entry = < span class = "fn" > GetOrCreate< / span > (songId);
< span class = "ann" > // Contains: 이미 있으면 중복 추가 방지< / span >
< span class = "kw" > if< / span > (!entry.difficulties.< span class = "fn" > Contains< / span > (difficulty))
entry.difficulties.< span class = "fn" > Add< / span > (difficulty);
< span class = "ann" > // "o" 형식 = ISO 8601 (2026-05-22T13:00:00Z). UTC 기준으로 시간대 무관하게 비교 가능< / span >
entry.lastAccessedAt = < span class = "ty" > DateTime< / span > .UtcNow.< span class = "fn" > ToString< / span > (< span class = "st" > "o"< / span > );
< span class = "fn" > Save< / span > (); < span class = "ann" > // 즉시 파일에 저장 (앱 크래시해도 데이터 보존)< / span >
}
< span class = "kw" > public void< / span > < span class = "fn" > MarkSongRemoved< / span > (< span class = "ty" > string< / span > songId)
{
< span class = "ann" > // RemoveAll: 조건 람다를 만족하는 모든 항목 제거< / span >
_data.entries.< span class = "fn" > RemoveAll< / span > (e => e.songId == songId);
< span class = "fn" > Save< / span > ();
}
< span class = "kw" > public bool< / span > < span class = "fn" > IsSongDownloaded< / span > (< span class = "ty" > string< / span > songId) => < span class = "fn" > Find< / span > (songId) != < span class = "kw" > null< / span > ;
< span class = "ann" > // ?. = null 조건부 접근. Find가 null이면 false 반환 (NullReferenceException 없음)< / span >
< span class = "ann" > // ?? false = Contains 결과가 null일 수 없지만 ?. 때문에 nullable → ?? 로 기본값< / span >
< span class = "kw" > public bool< / span > < span class = "fn" > IsDifficultyDownloaded< / span > (< span class = "ty" > string< / span > songId, < span class = "ty" > string< / span > difficulty)
=> < span class = "fn" > Find< / span > (songId)?.difficulties.< span class = "fn" > Contains< / span > (difficulty) ?? < span class = "kw" > false< / span > ;
< span class = "ann" > // ── 파일시스템 검증: 라이브러리 기록 vs 실제 파일 동기화 ─────< / span >
< span class = "ann" > // 실제 파일이 없는데 라이브러리엔 있는 경우 (외부 삭제 등) 제거< / span >
< span class = "kw" > public void< / span > < span class = "fn" > ValidateWithFileSystem< / span > (< span class = "ty" > DownloadManager< / span > dm, < span class = "ty" > List< / span > < < span class = "ty" > SongInfo< / span > > songs)
{
< span class = "kw" > bool< / span > dirty = < span class = "kw" > false< / span > ;
< span class = "kw" > foreach< / span > (< span class = "ty" > SongInfo< / span > song < span class = "kw" > in< / span > songs)
{
< span class = "ty" > LibraryEntry< / span > entry = < span class = "fn" > Find< / span > (song.id);
< span class = "kw" > if< / span > (entry == < span class = "kw" > null< / span > ) < span class = "kw" > continue< / span > ;
< span class = "ann" > // MP3 파일 없으면 이 곡 전체 제거< / span >
< span class = "kw" > if< / span > (!dm.< span class = "fn" > IsSongDownloaded< / span > (song.id))
{ _data.entries.< span class = "fn" > Remove< / span > (entry); dirty = < span class = "kw" > true< / span > ; < span class = "kw" > continue< / span > ; }
< span class = "ann" > // 각 난이도 파일 없는 것 제거< / span >
entry.difficulties.< span class = "fn" > RemoveAll< / span > (d => !dm.< span class = "fn" > IsDifficultyDownloaded< / span > (song, d));
< span class = "kw" > if< / span > (entry.difficulties.Count == < span class = "nm" > 0< / span > )
{ _data.entries.< span class = "fn" > Remove< / span > (entry); dirty = < span class = "kw" > true< / span > ; }
}
< span class = "ann" > // dirty 플래그: 변경이 있을 때만 파일 저장 (불필요한 IO 방지)< / span >
< span class = "kw" > if< / span > (dirty) < span class = "fn" > Save< / span > ();
}
< span class = "kw" > private< / span > < span class = "ty" > LibraryEntry< / span > < span class = "fn" > Find< / span > (< span class = "ty" > string< / span > songId)
=> _data.entries.< span class = "fn" > Find< / span > (e => e.songId == songId); < span class = "ann" > // 없으면 null< / span >
< span class = "kw" > private< / span > < span class = "ty" > LibraryEntry< / span > < span class = "fn" > GetOrCreate< / span > (< span class = "ty" > string< / span > songId)
{
< span class = "ty" > LibraryEntry< / span > entry = < span class = "fn" > Find< / span > (songId);
< span class = "kw" > if< / span > (entry != < span class = "kw" > null< / span > ) < span class = "kw" > return< / span > entry;
entry = < span class = "kw" > new< / span > < span class = "ty" > LibraryEntry< / span > { songId = songId };
_data.entries.< span class = "fn" > Add< / span > (entry);
< span class = "kw" > return< / span > entry;
}
< span class = "kw" > private void< / span > < span class = "fn" > Load< / span > ()
{
< span class = "kw" > if< / span > (!File.< span class = "fn" > Exists< / span > (SavePath)) < span class = "kw" > return< / span > ;
< span class = "kw" > try< / span >
{
< span class = "ty" > string< / span > json = File.< span class = "fn" > ReadAllText< / span > (SavePath);
< span class = "ann" > // ?? new: 파일이 있지만 파싱 실패하면 빈 데이터로 초기화< / span >
_data = JsonUtility.< span class = "fn" > FromJson< / span > < < span class = "ty" > LibraryData< / span > > (json) ?? < span class = "kw" > new< / span > < span class = "ty" > LibraryData< / span > ();
}
< span class = "kw" > catch< / span > (< span class = "ty" > Exception< / span > e)
{
Debug.< span class = "fn" > LogWarning< / span > ($< span class = "st" > "[SongLibrary] 로드 실패, 초기화: {e.Message}"< / span > );
_data = < span class = "kw" > new< / span > < span class = "ty" > LibraryData< / span > ();
}
}
< span class = "kw" > private void< / span > < span class = "fn" > Save< / span > () => File.< span class = "fn" > WriteAllText< / span > (SavePath, JsonUtility.< span class = "fn" > ToJson< / span > (_data, < span class = "kw" > true< / span > ));
}
[< span class = "ty" > Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > LibraryData< / span >
{
< span class = "kw" > public< / span > < span class = "ty" > List< / span > < < span class = "ty" > LibraryEntry< / span > > entries = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > LibraryEntry< / span > > ();
}
[< span class = "ty" > Serializable< / span > ] < span class = "kw" > public class< / span > < span class = "ty" > LibraryEntry< / span >
{
< span class = "kw" > public string< / span > songId;
< span class = "kw" > public< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > difficulties = < span class = "kw" > new< / span > < span class = "ty" > List< / span > < < span class = "ty" > string< / span > > (); < span class = "ann" > // ["normal","hard"]< / span >
< span class = "kw" > public string< / span > lastAccessedAt; < span class = "ann" > // ISO 8601 UTC 시각< / span >
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ MarqueeText.cs ══════════════════════ -->
< div id = "p-marquee" class = "panel" >
< div class = "file-header" >
< h1 > MarqueeText.cs< / h1 >
< p > 텍스트가 컨테이너보다 길 때 자동으로 옆으로 스크롤하는 컴포넌트. 정지 → 왼쪽 스크롤 → 정지 → 반복.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > MarqueeText.cs< / span > < / div > < pre >
< span class = "ann" > // [RequireComponent] = TMP_Text 없이 이 컴포넌트만 붙이면 Unity가 자동으로 TMP_Text도 추가< / span >
[< span class = "ty" > RequireComponent< / span > (< span class = "kw" > typeof< / span > (< span class = "ty" > TMP_Text< / span > ))]
< span class = "kw" > public class< / span > < span class = "ty" > MarqueeText< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "kw" > public float< / span > speed = < span class = "nm" > 35f< / span > ; < span class = "ann" > // 스크롤 속도 (units/초)< / span >
< span class = "kw" > public float< / span > pauseStart = < span class = "nm" > 1.5f< / span > ; < span class = "ann" > // 시작 위치에서 대기 시간< / span >
< span class = "kw" > public float< / span > pauseEnd = < span class = "nm" > 0.6f< / span > ; < span class = "ann" > // 끝 위치에서 대기 시간 후 처음으로 돌아감< / span >
< span class = "kw" > private void< / span > < span class = "fn" > Awake< / span > ()
{
_label = < span class = "fn" > GetComponent< / span > < < span class = "ty" > TMP_Text< / span > > ();
_rect = < span class = "fn" > GetComponent< / span > < < span class = "ty" > RectTransform< / span > > ();
}
< span class = "ann" > // Start가 IEnumerator를 반환하면 Unity가 자동으로 코루틴으로 실행< / span >
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > Start< / span > ()
{
< span class = "ann" > // yield return null: 1프레임 대기. 레이아웃이 완전히 계산된 후 크기를 읽어야 정확하다< / span >
< span class = "ann" > // Awake/Start에서 바로 읽으면 0이 나올 수 있음< / span >
< span class = "kw" > yield return null< / span > ;
_label.< span class = "fn" > ForceMeshUpdate< / span > (); < span class = "ann" > // TMP 메시 강제 갱신 → preferredWidth가 정확한 값 반환< / span >
< span class = "kw" > float< / span > textW = _label.preferredWidth; < span class = "ann" > // 텍스트 실제 너비< / span >
< span class = "kw" > float< / span > containerW = ((< span class = "ty" > RectTransform< / span > )transform.parent).rect.width; < span class = "ann" > // 부모(TitleMask) 너비< / span >
< span class = "kw" > float< / span > dist = textW - containerW; < span class = "ann" > // 스크롤해야 할 거리< / span >
< span class = "ann" > // 텍스트가 컨테이너보다 1 unit 이상 길 때만 마퀴 시작. 짧으면 정지 상태 유지< / span >
< span class = "kw" > if< / span > (dist > < span class = "nm" > 1f< / span > ) < span class = "fn" > StartCoroutine< / span > (< span class = "fn" > ScrollLoop< / span > (dist));
}
< span class = "kw" > private< / span > < span class = "ty" > IEnumerator< / span > < span class = "fn" > ScrollLoop< / span > (< span class = "kw" > float< / span > dist)
{
< span class = "kw" > while< / span > (< span class = "kw" > true< / span > ) < span class = "ann" > // 무한 반복< / span >
{
< span class = "fn" > SetX< / span > (< span class = "nm" > 0f< / span > ); < span class = "ann" > // 시작 위치(왼쪽 끝) 복귀< / span >
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (pauseStart); < span class = "ann" > // 1.5초 정지< / span >
< span class = "kw" > float< / span > x = < span class = "nm" > 0f< / span > ;
< span class = "ann" > // x가 -dist에 도달할 때까지 매 프레임 이동 (왼쪽으로 스크롤)< / span >
< span class = "kw" > while< / span > (x > -dist)
{
< span class = "ann" > // MoveTowards: 현재값에서 목표값 방향으로 최대 speed*dt 만큼 이동< / span >
< span class = "ann" > // 단순 x -= speed*dt 와 달리 목표값을 절대 넘지 않음< / span >
x = Mathf.< span class = "fn" > MoveTowards< / span > (x, -dist, speed * < span class = "ty" > Time< / span > .deltaTime);
< span class = "fn" > SetX< / span > (x);
< span class = "kw" > yield return null< / span > ; < span class = "ann" > // 매 프레임 실행< / span >
}
< span class = "kw" > yield return new< / span > < span class = "ty" > WaitForSeconds< / span > (pauseEnd); < span class = "ann" > // 0.6초 정지 후 처음으로< / span >
}
}
< span class = "ann" > // anchoredPosition의 y는 유지하고 x만 변경< / span >
< span class = "kw" > private void< / span > < span class = "fn" > SetX< / span > (< span class = "kw" > float< / span > x) =>
_rect.anchoredPosition = < span class = "kw" > new< / span > < span class = "ty" > Vector2< / span > (x, _rect.anchoredPosition.y);
}
< / pre > < / div >
< / div >
2026-05-26 19:12:06 +09:00
<!-- ══════════════════════ 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 >
2026-05-26 00:18:32 +09:00
<!-- ══════════════════════ DesktopUIMode.cs ══════════════════════ -->
< div id = "p-desktop" class = "panel" >
< div class = "file-header" >
< h1 > DesktopUIMode.cs< / h1 >
< p > 에디터/PC 전용 헬퍼. Quest 빌드에선 < strong > 컴파일 자체가 안 된다< / strong > . 마우스 클릭 활성화 + ESC 뒤로가기.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > DesktopUIMode.cs< / span > < / div > < pre >
< span class = "ann" > // #if !UNITY_ANDROID || UNITY_EDITOR< / span >
< span class = "ann" > // = "안드로이드가 아니거나(PC/Mac) 에디터면" → Quest 실제 빌드에서는 이 파일 전체가 제외됨< / span >
< span class = "kw" > public class< / span > < span class = "ty" > DesktopUIMode< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // 이하 코드는 에디터 또는 PC 빌드에서만 포함< / span >
#< span class = "kw" > if< / span > !UNITY_ANDROID || UNITY_EDITOR
< span class = "ann" > // [RuntimeInitializeOnLoadMethod] = 씬 시작 시 Unity가 자동 호출하는 static 메서드< / span >
< span class = "ann" > // AfterSceneLoad = 씬 오브젝트 Awake/Start 후에 실행< / span >
< span class = "ann" > // → 씬에 직접 배치하지 않아도 자동으로 생성됨< / span >
[< span class = "ty" > RuntimeInitializeOnLoadMethod< / span > (< span class = "ty" > RuntimeInitializeLoadType< / span > .AfterSceneLoad)]
< span class = "kw" > private static void< / span > < span class = "fn" > AutoCreate< / span > ()
{
2026-05-26 19:12:06 +09:00
< 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 >
2026-05-26 00:18:32 +09:00
< 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 > > ();
}
< span class = "ann" > // 씬별 뒤로가기 맵: ESC 누르면 이 씬으로 이동< / span >
< span class = "kw" > private static readonly< / span > < span class = "ty" > Dictionary< / span > < < span class = "ty" > string< / span > , < span class = "ty" > string< / span > > BackMap = < span class = "kw" > new< / span > ()
{
{ < span class = "st" > "SongSelect"< / span > , < span class = "st" > "Menu"< / span > }, { < span class = "st" > "SongCreator"< / span > , < span class = "st" > "Menu"< / span > },
{ < span class = "st" > "Game"< / span > , < span class = "st" > "SongSelect"< / span > },
};
< span class = "kw" > private void< / span > < span class = "fn" > Awake< / span > ()
{
< span class = "ann" > // FindObjectsByType: FindObjectsOfType의 최신 버전. 정렬 없음 = 더 빠름< / span >
< span class = "kw" > if< / span > (< span class = "fn" > FindObjectsByType< / span > < < span class = "ty" > DesktopUIMode< / span > > (< span class = "ty" > FindObjectsSortMode< / span > .None).Length > < span class = "nm" > 1< / span > )
{ < span class = "fn" > Destroy< / span > (gameObject); < span class = "kw" > return< / span > ; } < span class = "ann" > // 중복 방지< / span >
< span class = "ty" > DontDestroyOnLoad< / span > (gameObject);
< span class = "ann" > // 이벤트 구독: 씬 로드될 때마다 OnSceneLoaded 호출< / span >
< span class = "ty" > SceneManager< / span > .sceneLoaded += < span class = "fn" > OnSceneLoaded< / span > ;
< span class = "fn" > PatchCanvases< / span > ();
}
< span class = "ann" > // OnDestroy에서 이벤트 구독 해제 → 메모리 누수 방지< / span >
< span class = "kw" > private void< / span > < span class = "fn" > OnDestroy< / span > () => < span class = "ty" > SceneManager< / span > .sceneLoaded -= < span class = "fn" > OnSceneLoaded< / span > ;
< span class = "ann" > // 씬 로드 직후 1프레임 대기 후 패치 (새 씬 오브젝트가 모두 Awake 된 후)< / span >
< span class = "kw" > private void< / span > < span class = "fn" > OnSceneLoaded< / span > (< span class = "ty" > Scene< / span > s, < span class = "ty" > LoadSceneMode< / span > m) => < span class = "fn" > StartCoroutine< / span > (< span class = "fn" > PatchAfterFrame< / span > ());
< span class = "kw" > private< / span > < span class = "ty" > System.Collections.IEnumerator< / span > < span class = "fn" > PatchAfterFrame< / span > ()
{ < span class = "kw" > yield return null< / span > ; < span class = "fn" > PatchCanvases< / span > (); }
< span class = "kw" > private void< / span > < span class = "fn" > Update< / span > ()
{
< span class = "fn" > RefreshCanvasCameras< / span > ();
< span class = "ann" > // Keyboard.current?. = Input System. ?. = 키보드 없으면 null 안전< / span >
< span class = "ann" > // wasPressedThisFrame = 이번 프레임에 처음 눌렸을 때만 true< / span >
< span class = "kw" > if< / span > (< span class = "ty" > Keyboard< / span > .current?.escapeKey.wasPressedThisFrame == < span class = "kw" > true< / span > ) < span class = "fn" > GoBack< / span > ();
}
< span class = "kw" > private static void< / span > < span class = "fn" > PatchCanvases< / 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))
{
< span class = "kw" > if< / span > (canvas.renderMode != < span class = "ty" > RenderMode< / span > .WorldSpace) < span class = "kw" > continue< / span > ;
< span class = "ann" > // VRBeatsKit Canvas에는 TrackedDeviceGraphicRaycaster가 붙어있음< / span >
< span class = "ann" > // XR Raycaster는 PC에서 마우스 클릭 안 됨 → 일반 GraphicRaycaster로 교체< / span >
< span class = "kw" > var< / span > tracked = canvas.< span class = "fn" > GetComponent< / span > (< span class = "st" > "TrackedDeviceGraphicRaycaster"< / span > );
< span class = "kw" > if< / span > (tracked != < span class = "kw" > null< / span > )
{
< span class = "ty" > DestroyImmediate< / span > (tracked); < span class = "ann" > // 즉시 삭제 (다음 줄에서 null 체크)< / span >
< span class = "kw" > if< / span > (canvas.< span class = "fn" > GetComponent< / span > < < span class = "ty" > GraphicRaycaster< / span > > () == < span class = "kw" > null< / span > )
canvas.gameObject.< span class = "fn" > AddComponent< / span > < < span class = "ty" > GraphicRaycaster< / span > > ();
}
}
< span class = "fn" > RemoveDuplicateAudioListeners< / span > (); < span class = "ann" > // AudioListener 중복 시 경고 방지< / span >
< span class = "fn" > RefreshCanvasCameras< / span > ();
}
< span class = "kw" > private static void< / span > < span class = "fn" > RefreshCanvasCameras< / span > ()
{
< span class = "ann" > // WorldSpace Canvas는 worldCamera가 설정돼야 화면에 올바르게 렌더링됨< / span >
< span class = "ty" > Camera< / span > cam = < span class = "ty" > Camera< / span > .main;
< 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 > ; }
2026-05-26 19:12:06 +09:00
cam ??= < span class = "fn" > FindFirstObjectByType< / span > < < span class = "ty" > Camera< / span > > (); < span class = "ann" > // 최후의 수단< / span >
2026-05-26 00:18:32 +09:00
< 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))
< span class = "kw" > if< / span > (canvas.renderMode == < span class = "ty" > RenderMode< / span > .WorldSpace & & canvas.worldCamera != cam)
canvas.worldCamera = cam;
}
< span class = "ann" > // TryGetValue: 키가 없으면 false 반환 (KeyNotFoundException 없음)< / span >
< span class = "kw" > private static void< / span > < span class = "fn" > GoBack< / span > ()
{
< span class = "kw" > if< / span > (BackMap.< span class = "fn" > TryGetValue< / span > (< span class = "ty" > SceneManager< / span > .< span class = "fn" > GetActiveScene< / span > ().name, < span class = "kw" > out< / span > < span class = "ty" > string< / span > target))
< span class = "ty" > SceneManager< / span > .< span class = "fn" > LoadScene< / span > (target);
}
#< span class = "kw" > endif< / span >
}
< / pre > < / div >
< / div >
<!-- ══════════════════════ XRSimulatorLoader.cs ══════════════════════ -->
< div id = "p-xrloader" class = "panel" >
< div class = "file-header" >
< h1 > XRSimulatorLoader.cs< / h1 >
< p > 에디터/PC에서 XR Interaction Simulator 프리팹을 자동으로 생성해주는 단순 로더.< / p >
< / div >
< div class = "cw" > < div class = "ch" > < span > XRSimulatorLoader.cs< / span > < / div > < pre >
< span class = "kw" > public class< / span > < span class = "ty" > XRSimulatorLoader< / span > : < span class = "ty" > MonoBehaviour< / span >
{
< span class = "ann" > // Inspector에서 XR Interaction Simulator 프리팹을 연결< / span >
< span class = "ann" > // 경로: Assets/Samples/XR Interaction Toolkit/버전/XR Interaction Simulator/< / span >
[< span class = "ty" > SerializeField< / span > ] < span class = "kw" > private< / span > < span class = "ty" > GameObject< / span > simulatorPrefab;
< span class = "kw" > private void< / span > < span class = "fn" > Awake< / span > ()
{
< span class = "ann" > // 에디터 또는 PC 빌드에서만 실행. Quest Android 빌드에선 이 블록이 제외됨< / span >
#< span class = "kw" > if< / span > !UNITY_ANDROID || UNITY_EDITOR
< span class = "kw" > if< / span > (simulatorPrefab != < span class = "kw" > null< / span > )
< span class = "fn" > Instantiate< / span > (simulatorPrefab); < span class = "ann" > // 씬에 프리팹 인스턴스 생성< / span >
< span class = "kw" > else< / span >
Debug.< span class = "fn" > LogWarning< / span > (< span class = "st" > "[XRSimulatorLoader] simulatorPrefab is not assigned."< / span > );
#< span class = "kw" > endif< / span >
}
< span class = "ann" > // 조작 방법 (에디터 실행 시):< / span >
< span class = "ann" > // 우클릭 드래그 = 머리 회전 | G + 마우스 = 오른쪽 컨트롤러 | Shift+G = 왼쪽< / span >
< span class = "ann" > // Space = 트리거 (UI 클릭)< / span >
}
< / pre > < / div >
< / div >
< / div > <!-- /main -->
< script >
function show ( id ) {
document . querySelectorAll ( '.panel' ) . forEach ( p => p . classList . remove ( 'active' ) ) ;
document . querySelectorAll ( '.tab-btn' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
document . getElementById ( 'p-' + id ) . classList . add ( 'active' ) ;
event . currentTarget . classList . add ( 'active' ) ;
document . getElementById ( 'main' ) . scrollTop = 0 ;
}
< / script >
< / body >
< / html >