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