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; + + private AudioManager _audio; // VRBeatsKit AudioManager: 실제 AudioSource 래핑 + + // static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다 + private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber"); + + private void Start() + { + // FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출) + _audio = FindObjectOfType<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 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리 + map.target.Sort((a, b) => a.time.CompareTo(b.time)); + + // ── 카운트다운 → 음악 시작 → 스폰 루프 ────────────────── + 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열 그리드 → 월드 X 좌표 선형 매핑 + // 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭) + float x = -0.375f + note.position * 0.25f; + // 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위) + float y = -0.333f + note.lineLayer * 0.333f; + + // ★ 핵심: 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); + } + + // ── 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 내장 오디오 관리자. 우리 코드에서 PlayClip()과 CurrentTime만 추가했다.
+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 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을 재생할 때 호출 + public void PlayClip(AudioClip clip) + { + audioSource.clip = clip; // 재생할 클립 교체 + audioSource.Play(); // 즉시 재생 시작 + } + + // 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌다 + // audioSource가 null이면 0 반환 (씬 초기화 중 안전) + public float CurrentTime => audioSource != null ? audioSource.time : 0f; + } +} +
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.enableWordWrapping = false; // 줄바꿈 금지 → 한 줄로 + 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); +} +
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 (FindObjectOfType<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 ??= FindObjectOfType<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 클릭) +} +