feat: SongSelect panel — NAS song list with download/play in Menu.unity

- Add DownloadManager, SongLibrary, SongSelectManager, SongDetailPanel scripts
- Rebuild SongSelect panel inside Menu.unity using VRBeatsKit style:
  left scroll list (ALL/OWNED tabs) + right detail panel (diff buttons, Download/Delete/Play)
- SceneBuilder replaced: only ③ Menu — Rebuild SongSelect Panel remains
- All UI text in English

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 01:22:16 +09:00
parent 4dad9e5d5b
commit 58c88dafff
11 changed files with 5857 additions and 559 deletions
+160
View File
@@ -0,0 +1,160 @@
using System;
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
public void FetchSongsList(Action<SongsList> onSuccess, Action<string> onError = null)
{
StartCoroutine(GetText($"{baseUrl}/songs.json", json =>
{
SongsList list = JsonUtility.FromJson<SongsList>(json);
if (list == null)
onError?.Invoke("songs.json 파싱 실패");
else
onSuccess?.Invoke(list);
}, onError));
}
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);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
}
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);
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)
{
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
// 1단계: 오디오 (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),
err => { onError?.Invoke(err); failed = true; });
if (failed) yield break;
}
// 2단계: 맵 파일 (30%)
DifficultyInfo diffInfo = song.difficulties.Get(difficulty);
if (diffInfo == null)
{
onError?.Invoke($"난이도 '{difficulty}' 없음");
yield break;
}
if (string.IsNullOrEmpty(diffInfo.mapFile))
{
onError?.Invoke($"'{difficulty}' 맵 파일 정보 없음 — Creator에서 곡을 다시 생성해주세요");
yield break;
}
string mapPath = MapPath(song, difficulty);
if (mapPath != null) mapPath = Path.GetFullPath(mapPath);
if (mapPath == null)
{
onError?.Invoke($"'{difficulty}' 맵 경로 계산 실패");
yield break;
}
if (!File.Exists(mapPath))
{
bool failed = false;
yield return DownloadFile(
$"{baseUrl}/{diffInfo.mapFile}", mapPath,
p => onProgress?.Invoke(0.7f + p * 0.3f),
err => { onError?.Invoke(err); failed = true; });
if (failed) yield break;
}
onProgress?.Invoke(1f);
onComplete?.Invoke();
Debug.Log($"[DownloadManager] 완료: {song.title} ({difficulty})");
}
private IEnumerator DownloadFile(string url, string savePath,
Action<float> onProgress, Action<string> onError)
{
using var req = UnityWebRequest.Get(url);
req.downloadHandler = new DownloadHandlerFile(savePath);
req.SendWebRequest();
while (!req.isDone)
{
onProgress?.Invoke(req.downloadProgress);
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);
}
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a8efd2469f7355140ae71425ecc638e0
+230
View File
@@ -0,0 +1,230 @@
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SongDetailPanel : MonoBehaviour
{
[Header("곡 정보")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text artistText;
[SerializeField] private TMP_Text infoText;
[Header("난이도 버튼")]
[SerializeField] private Button btnNormal;
[SerializeField] private Button btnHard;
[SerializeField] private Button btnExpert;
[SerializeField] private Button btnExpertPlus;
[Header("액션 버튼")]
[SerializeField] private Button downloadButton;
[SerializeField] private Button deleteButton;
[SerializeField] private Button playButton;
[SerializeField] private Button closeButton;
[Header("진행률")]
[SerializeField] private GameObject progressGroup;
[SerializeField] private Slider progressSlider;
[SerializeField] private TMP_Text progressText;
[Header("씬 이름")]
[SerializeField] private string gameSceneName = "Game";
private static readonly Color SelectedColor = new Color(0.4f, 0.8f, 1f);
private static readonly Color DeselectedColor = Color.white;
private SongInfo currentSong;
private string selectedDifficulty;
private DownloadManager downloadManager;
private SongSelectManager selectManager;
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 API ───────────────────────────────────────────
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;
infoText.text = song.duration > 0
? $"BPM {Mathf.RoundToInt(song.bpm)} | {FormatDuration(song.duration)}"
: $"BPM {Mathf.RoundToInt(song.bpm)}";
RefreshUI();
}
// ── UI 갱신 ──────────────────────────────────────────────
private void RefreshUI()
{
bool downloaded = SongLibrary.Instance.IsSongDownloaded(currentSong.id);
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;
btn.onClick.AddListener(() => SelectDifficulty(captured));
}
}
UpdateDiffColors();
downloadButton.gameObject.SetActive(!downloaded);
deleteButton.gameObject.SetActive(downloaded);
playButton.interactable = downloaded && selectedDifficulty != null;
progressGroup.SetActive(false);
downloadButton.onClick.RemoveAllListeners();
downloadButton.onClick.AddListener(OnDownloadClicked);
deleteButton.onClick.RemoveAllListeners();
deleteButton.onClick.AddListener(OnDeleteClicked);
playButton.onClick.RemoveAllListeners();
playButton.onClick.AddListener(OnPlayClicked);
if (closeButton != null)
{
closeButton.onClick.RemoveAllListeners();
closeButton.onClick.AddListener(() => gameObject.SetActive(false));
}
}
private void SelectDifficulty(string difficulty)
{
selectedDifficulty = difficulty;
playButton.interactable = true;
UpdateDiffColors();
}
private void UpdateDiffColors()
{
foreach (var (key, getBtn) in diffSlots)
{
Button btn = getBtn(this);
var colors = btn.colors;
colors.normalColor = (key == selectedDifficulty) ? SelectedColor : DeselectedColor;
btn.colors = colors;
}
}
// ── 다운로드 ──────────────────────────────────────────────
private void OnDownloadClicked()
{
StartCoroutine(DownloadAllCoroutine());
}
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);
downloadButton.gameObject.SetActive(false);
deleteButton.gameObject.SetActive(false);
playButton.gameObject.SetActive(false);
int totalSteps = diffs.Count;
int doneSteps = 0;
bool failed = false;
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($"[SongDetailPanel] {err}");
failed = true;
stepDone = true;
});
yield return new WaitUntil(() => stepDone);
if (failed) break;
}
SetInteractable(true);
progressGroup.SetActive(false);
playButton.gameObject.SetActive(true);
selectManager.RefreshCards();
RefreshUI();
if (!failed)
Debug.Log($"[SongDetailPanel] '{currentSong.title}' 전체 다운로드 완료");
}
// ── 삭제 ─────────────────────────────────────────────────
private void OnDeleteClicked()
{
downloadManager.DeleteSong(currentSong.id);
SongLibrary.Instance.MarkSongRemoved(currentSong.id);
selectedDifficulty = null;
selectManager.RefreshCards();
RefreshUI();
}
// ── 플레이 ───────────────────────────────────────────────
private void OnPlayClicked()
{
GameSession.SelectedSong = currentSong;
GameSession.SelectedDifficulty = selectedDifficulty;
SceneManager.LoadScene(gameSceneName);
}
// ── 유틸 ─────────────────────────────────────────────────
private void SetInteractable(bool value)
{
downloadButton.interactable = value;
deleteButton.interactable = value;
playButton.interactable = value && selectedDifficulty != null;
foreach (var (_, getBtn) in diffSlots)
getBtn(this).interactable = value;
}
private static string FormatDuration(int seconds)
=> $"{seconds / 60}:{seconds % 60:D2}";
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d27acdc84ca9a6241894ce7ee9f3c3fa
+140
View File
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public class SongLibrary : MonoBehaviour
{
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()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
Load();
}
// ── Public API ───────────────────────────────────────────
public void MarkDownloaded(string songId, string difficulty)
{
LibraryEntry entry = GetOrCreate(songId);
if (!entry.difficulties.Contains(difficulty))
entry.difficulties.Add(difficulty);
entry.lastAccessedAt = DateTime.UtcNow.ToString("o");
Save();
}
public void MarkDifficultyRemoved(string songId, string difficulty)
{
LibraryEntry entry = Find(songId);
if (entry == null) return;
entry.difficulties.Remove(difficulty);
if (entry.difficulties.Count == 0)
_data.entries.Remove(entry);
Save();
}
public void MarkSongRemoved(string songId)
{
_data.entries.RemoveAll(e => e.songId == songId);
Save();
}
public void TouchSong(string songId)
{
LibraryEntry entry = Find(songId);
if (entry == null) return;
entry.lastAccessedAt = DateTime.UtcNow.ToString("o");
Save();
}
public bool IsSongDownloaded(string songId)
=> Find(songId) != null;
public bool IsDifficultyDownloaded(string songId, string difficulty)
=> Find(songId)?.difficulties.Contains(difficulty) ?? false;
public List<LibraryEntry> GetAll()
=> _data.entries;
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;
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;
}
}
if (dirty) Save();
}
// ── 내부 구현 ─────────────────────────────────────────────
private LibraryEntry Find(string songId)
=> _data.entries.Find(e => e.songId == songId);
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);
_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>();
public string lastAccessedAt;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 765cf3a9cd9c14943be42e1cee050abd
+200
View File
@@ -0,0 +1,200 @@
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class SongSelectManager : MonoBehaviour
{
[SerializeField] private Button tabAllBtn;
[SerializeField] private Button tabOwnedBtn;
[SerializeField] private RectTransform cardContainer;
[SerializeField] private SongDetailPanel detailPanel;
[SerializeField] private DownloadManager downloadManager;
[SerializeField] private GameObject loadingOverlay;
[SerializeField] private GameObject errorOverlay;
[SerializeField] private TMP_Text errorText;
private static readonly Color TabActive = Color.white;
private static readonly Color TabInactive = new Color(0.6f, 0.6f, 0.6f);
private static string CachePath =>
Path.Combine(Application.persistentDataPath, "songs_cache.json");
private List<SongInfo> allSongs = new List<SongInfo>();
private bool showingOwned = false;
private void Start()
{
tabAllBtn .onClick.AddListener(() => SwitchTab(false));
tabOwnedBtn.onClick.AddListener(() => SwitchTab(true));
detailPanel.gameObject.SetActive(false);
SetTabVisual(false);
FetchSongs();
}
private void SwitchTab(bool owned)
{
showingOwned = owned;
SetTabVisual(owned);
RefreshCards();
}
private void SetTabVisual(bool owned)
{
ApplyTabColor(tabAllBtn, owned ? TabInactive : TabActive);
ApplyTabColor(tabOwnedBtn, owned ? TabActive : TabInactive);
}
private static void ApplyTabColor(Button btn, Color c)
{
var colors = btn.colors;
colors.normalColor = c;
btn.colors = colors;
}
private void FetchSongs()
{
loadingOverlay.SetActive(true);
errorOverlay .SetActive(false);
downloadManager.FetchSongsList(
onSuccess: list =>
{
allSongs = list.songs;
SaveCache(list);
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()
{
for (int i = cardContainer.childCount - 1; i >= 0; i--)
Destroy(cardContainer.GetChild(i).gameObject);
List<SongInfo> songs = showingOwned
? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id))
: allSongs;
foreach (SongInfo song in songs)
SpawnCard(song);
}
private void SpawnCard(SongInfo song)
{
bool downloaded = SongLibrary.Instance.IsSongDownloaded(song.id);
var card = new GameObject(song.title);
card.transform.SetParent(cardContainer, false);
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.60f, 0.9f, 0.45f);
bc.fadeDuration = 0.1f;
btn.colors = bc;
// Title
var titleGO = new GameObject("Title");
titleGO.transform.SetParent(card.transform, false);
var tr = titleGO.AddComponent<RectTransform>();
tr.anchorMin = new Vector2(0f, 0.5f);
tr.anchorMax = new Vector2(1f, 1f);
tr.offsetMin = new Vector2(5f, 0f);
tr.offsetMax = new Vector2(downloaded ? -18f : -3f, -1f);
var tTmp = titleGO.AddComponent<TextMeshProUGUI>();
tTmp.text = song.title;
tTmp.fontSize = 5f;
tTmp.color = Color.white;
tTmp.alignment = TextAlignmentOptions.MidlineLeft;
tTmp.overflowMode = TextOverflowModes.Ellipsis;
// Artist
var artistGO = new GameObject("Artist");
artistGO.transform.SetParent(card.transform, false);
var ar = artistGO.AddComponent<RectTransform>();
ar.anchorMin = new Vector2(0f, 0f);
ar.anchorMax = new Vector2(1f, 0.5f);
ar.offsetMin = new Vector2(5f, 1f);
ar.offsetMax = new Vector2(-3f, 0f);
var aTmp = artistGO.AddComponent<TextMeshProUGUI>();
aTmp.text = song.artist;
aTmp.fontSize = 4f;
aTmp.color = new Color(1f, 1f, 1f, 0.6f);
aTmp.alignment = TextAlignmentOptions.MidlineLeft;
// Downloaded badge
if (downloaded)
{
var badge = new GameObject("Badge");
badge.transform.SetParent(card.transform, false);
var br = badge.AddComponent<RectTransform>();
br.anchorMin = new Vector2(1f, 0.5f);
br.anchorMax = new Vector2(1f, 0.5f);
br.pivot = new Vector2(1f, 0.5f);
br.anchoredPosition = new Vector2(-3f, 0f);
br.sizeDelta = new Vector2(14f, 5.5f);
badge.AddComponent<Image>().color = new Color(0.2f, 0.78f, 0.4f, 0.85f);
var bl = new GameObject("Text");
bl.transform.SetParent(badge.transform, false);
var blr = bl.AddComponent<RectTransform>();
blr.anchorMin = Vector2.zero;
blr.anchorMax = Vector2.one;
blr.offsetMin = blr.offsetMax = Vector2.zero;
var blTmp = bl.AddComponent<TextMeshProUGUI>();
blTmp.text = "OWNED";
blTmp.fontSize = 3.5f;
blTmp.color = Color.white;
blTmp.alignment = TextAlignmentOptions.Center;
}
SongInfo captured = song;
btn.onClick.AddListener(() => OnCardClicked(captured));
}
private void OnCardClicked(SongInfo song)
{
detailPanel.gameObject.SetActive(true);
detailPanel.Show(song, downloadManager, this);
}
private static void SaveCache(SongsList list)
{
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; }
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 46403458ee5537142ad1e0b2ce7d3995