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
+267 -436
View File
@@ -1,281 +1,316 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
using TMPro;
public static class VRBeatSaberSceneBuilder
{
private const string MenuScene = "Assets/VRBeatsKit/Scenes/Menu.unity";
private const string SongCreatorDest = "Assets/Scenes/SongCreator.unity";
private const string MenuScene = "Assets/VRBeatsKit/Scenes/Menu.unity";
// ─────────────────────────────────────────────
// ⓪ Fix — Set Graphics API to D3D11 (Oculus requirement)
// ③ Menu — Rebuild SongSelect Panel
//
// Canvas(SongSelect) size: 105.885 × 68.223
// BG child covers full canvas (stretch anchors)
// BG local coord origin = center
// X: -52.94 ~ +52.94
// Y: -34.11 ~ +34.11
// ─────────────────────────────────────────────
[MenuItem("Tools/VRBeatSaber/⓪ Fix — Set Graphics API to D3D11")]
public static void FixGraphicsAPI()
{
var d3d11 = new[] { GraphicsDeviceType.Direct3D11 };
PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.StandaloneWindows, false);
PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.StandaloneWindows64, false);
PlayerSettings.SetGraphicsAPIs(BuildTarget.StandaloneWindows, d3d11);
PlayerSettings.SetGraphicsAPIs(BuildTarget.StandaloneWindows64, d3d11);
Debug.Log("[SceneBuilder] ✓ Graphics API set to Direct3D11 for Windows.");
}
[MenuItem("Tools/VRBeatSaber/⓪ Fix — Allow HTTP connections")]
public static void FixAllowHttp()
{
PlayerSettings.insecureHttpOption = InsecureHttpOption.AlwaysAllowed;
Debug.Log("[SceneBuilder] ✓ Insecure HTTP connections allowed.");
}
// ─────────────────────────────────────────────
// Fix — Remove missing script components from open scene
// ─────────────────────────────────────────────
[MenuItem("Tools/VRBeatSaber/Fix — Remove Missing Scripts (open scene)")]
public static void RemoveMissingScripts()
{
int removed = 0;
foreach (var go in Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None))
{
var so = new SerializedObject(go);
var components = so.FindProperty("m_Component");
for (int i = components.arraySize - 1; i >= 0; i--)
{
var comp = components.GetArrayElementAtIndex(i)
.FindPropertyRelative("component")
.objectReferenceValue;
if (comp == null)
{
components.DeleteArrayElementAtIndex(i);
removed++;
}
}
so.ApplyModifiedPropertiesWithoutUndo();
}
var activeScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
EditorSceneManager.MarkSceneDirty(activeScene);
Debug.Log($"[SceneBuilder] ✓ Removed {removed} missing script(s) from '{activeScene.name}'.");
}
// ─────────────────────────────────────────────
// ① Menu — Add Song Creator Button
// ─────────────────────────────────────────────
[MenuItem("Tools/VRBeatSaber/① Menu — Add Song Creator Button")]
public static void PatchMenuAddSongCreatorButton()
[MenuItem("Tools/VRBeatSaber/③ Menu — Rebuild SongSelect Panel")]
public static void RebuildSongSelectPanel()
{
var scene = EditorSceneManager.OpenScene(MenuScene, OpenSceneMode.Single);
var settings = GameObject.Find("Settings");
if (settings == null) { Debug.LogError("[SceneBuilder] 'Settings' not found."); return; }
var songSelectGO = GameObject.Find("SongSelect");
if (songSelectGO == null) { Debug.LogError("[SceneBuilder] 'SongSelect' not found."); return; }
if (settings.transform.Find("BG/Song Creator") != null)
{ Debug.LogWarning("[SceneBuilder] Button already exists."); return; }
var bgTransform = songSelectGO.transform.Find("BG");
if (bgTransform == null) { Debug.LogError("[SceneBuilder] 'SongSelect/BG' not found."); return; }
var bg = settings.transform.Find("BG");
if (bg == null) { Debug.LogError("[SceneBuilder] 'Settings/BG' not found."); return; }
// Clear BG children
for (int i = bgTransform.childCount - 1; i >= 0; i--)
Object.DestroyImmediate(bgTransform.GetChild(i).gameObject);
var btnGO = CreateStyledButton(bg, "Song Creator", new Vector2(0f, -20f), new Vector2(80f, 14f), 8f);
var loader = btnGO.AddComponent<VRBeats.LoadSceneButton>();
InjectPrivate(loader, "button", btnGO.GetComponent<Button>());
InjectPrivate(loader, "sceneName", "SongCreator");
// Create/reuse SongSystem root GO for SongLibrary (must be root for DontDestroyOnLoad)
var sysGO = GameObject.Find("SongSystem");
if (sysGO == null) sysGO = new GameObject("SongSystem");
var oldLib = sysGO.GetComponent<SongLibrary>();
if (oldLib != null) Object.DestroyImmediate(oldLib);
var songLibrary = sysGO.AddComponent<SongLibrary>();
// Add/replace SongSelectManager + DownloadManager on SongSelect GO
var oldSSM = songSelectGO.GetComponent<SongSelectManager>();
if (oldSSM != null) Object.DestroyImmediate(oldSSM);
var oldDM = songSelectGO.GetComponent<DownloadManager>();
if (oldDM != null) Object.DestroyImmediate(oldDM);
var downloadManager = songSelectGO.AddComponent<DownloadManager>();
var songSelectManager = songSelectGO.AddComponent<SongSelectManager>();
var bg = bgTransform;
// ── Header ──────────────────────────────────────────
CreateLabel(bg, "Title", "SONG SELECT",
new Vector2(0f, 28.5f), new Vector2(100f, 9f), 8.5f,
Color.white, TextAlignmentOptions.Center);
CreateDivider(bg, "DivHeader", new Vector2(0f, 23.5f), new Vector2(104f, 0.5f));
var tabAllBtn = CreateStyledButton(bg, "TabAll", "ALL", new Vector2(-18f, 19.5f), new Vector2(30f, 7f), 5f);
var tabOwnedBtn = CreateStyledButton(bg, "TabOwned", "OWNED", new Vector2( 14f, 19.5f), new Vector2(30f, 7f), 5f);
CreateDivider(bg, "DivTabs", new Vector2(0f, 15.5f), new Vector2(104f, 0.5f));
// ── Content area: Y from 15 to -34.11, height ~49 ───
// ListPanel (left half)
var listPanelGO = new GameObject("ListPanel");
listPanelGO.transform.SetParent(bg, false);
SetRect(listPanelGO, new Vector2(-26.6f, -9.4f), new Vector2(52.7f, 49f));
// Vertical divider
CreateDivider(bg, "DivVertical", new Vector2(0.1f, -9.4f), new Vector2(0.5f, 49f));
// DetailPanel (right half, hidden until card clicked)
var detailPanelGO = new GameObject("DetailPanel");
detailPanelGO.transform.SetParent(bg, false);
SetRect(detailPanelGO, new Vector2(26.6f, -9.4f), new Vector2(52.7f, 49f));
// ── ListPanel contents ───────────────────────────────
RectTransform scrollContent;
GameObject loadingOverlay;
GameObject errorOverlay;
TMP_Text errorText;
BuildScrollList(listPanelGO.transform,
out scrollContent, out loadingOverlay, out errorOverlay, out errorText);
// ── DetailPanel contents ─────────────────────────────
var detailPanelComp = detailPanelGO.AddComponent<SongDetailPanel>();
Button btnNormal, btnHard, btnExpert, btnExpertPlus;
Button downloadBtn, deleteBtn, playBtn, closeBtn;
GameObject progressGroup;
Slider progressSlider;
TMP_Text progressText;
TMP_Text titleTmp, artistTmp, infoTmp;
BuildDetailPanelUI(detailPanelGO.transform,
out titleTmp, out artistTmp, out infoTmp,
out btnNormal, out btnHard, out btnExpert, out btnExpertPlus,
out downloadBtn, out deleteBtn, out playBtn, out closeBtn,
out progressGroup, out progressSlider, out progressText);
detailPanelGO.SetActive(false);
// ── Wire SongDetailPanel refs ────────────────────────
var dpSO = new SerializedObject(detailPanelComp);
dpSO.FindProperty("titleText") .objectReferenceValue = titleTmp;
dpSO.FindProperty("artistText") .objectReferenceValue = artistTmp;
dpSO.FindProperty("infoText") .objectReferenceValue = infoTmp;
dpSO.FindProperty("btnNormal") .objectReferenceValue = btnNormal;
dpSO.FindProperty("btnHard") .objectReferenceValue = btnHard;
dpSO.FindProperty("btnExpert") .objectReferenceValue = btnExpert;
dpSO.FindProperty("btnExpertPlus") .objectReferenceValue = btnExpertPlus;
dpSO.FindProperty("downloadButton") .objectReferenceValue = downloadBtn;
dpSO.FindProperty("deleteButton") .objectReferenceValue = deleteBtn;
dpSO.FindProperty("playButton") .objectReferenceValue = playBtn;
dpSO.FindProperty("closeButton") .objectReferenceValue = closeBtn;
dpSO.FindProperty("progressGroup") .objectReferenceValue = progressGroup;
dpSO.FindProperty("progressSlider") .objectReferenceValue = progressSlider;
dpSO.FindProperty("progressText") .objectReferenceValue = progressText;
dpSO.FindProperty("gameSceneName") .stringValue = "Game";
dpSO.ApplyModifiedPropertiesWithoutUndo();
// ── Wire SongSelectManager refs ──────────────────────
var smSO = new SerializedObject(songSelectManager);
smSO.FindProperty("tabAllBtn") .objectReferenceValue = tabAllBtn.GetComponent<Button>();
smSO.FindProperty("tabOwnedBtn") .objectReferenceValue = tabOwnedBtn.GetComponent<Button>();
smSO.FindProperty("cardContainer") .objectReferenceValue = scrollContent;
smSO.FindProperty("detailPanel") .objectReferenceValue = detailPanelComp;
smSO.FindProperty("downloadManager").objectReferenceValue = downloadManager;
smSO.FindProperty("loadingOverlay") .objectReferenceValue = loadingOverlay;
smSO.FindProperty("errorOverlay") .objectReferenceValue = errorOverlay;
smSO.FindProperty("errorText") .objectReferenceValue = errorText;
smSO.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
Debug.Log("[SceneBuilder] ✓ 'Song Creator' button added to Menu > Settings > BG.");
Debug.Log("[SceneBuilder] ✓ SongSelect panel rebuilt in Menu.unity");
}
// ─────────────────────────────────────────────
// ② Build SongCreator Scene
// ListPanel: ScrollRect + overlays
// ─────────────────────────────────────────────
[MenuItem("Tools/VRBeatSaber/② Build SongCreator Scene")]
public static void BuildSongCreatorScene()
private static void BuildScrollList(Transform parent,
out RectTransform scrollContent,
out GameObject loadingOverlay,
out GameObject errorOverlay,
out TMP_Text errorText)
{
if (AssetDatabase.LoadAssetAtPath<Object>(SongCreatorDest) != null)
AssetDatabase.DeleteAsset(SongCreatorDest);
// ScrollRect (fills parent)
var scrollGO = new GameObject("Scroll");
scrollGO.transform.SetParent(parent, false);
StretchFull(scrollGO);
if (!AssetDatabase.CopyAsset(MenuScene, SongCreatorDest))
{
Debug.LogError("[SceneBuilder] Failed to copy Menu.unity → SongCreator.unity");
return;
}
AssetDatabase.Refresh();
// Viewport with Mask
var vpGO = new GameObject("Viewport");
vpGO.transform.SetParent(scrollGO.transform, false);
StretchFull(vpGO);
var vpImg = vpGO.AddComponent<Image>();
vpImg.color = new Color(0f, 0f, 0f, 0.01f);
vpGO.AddComponent<Mask>().showMaskGraphic = false;
var scene = EditorSceneManager.OpenScene(SongCreatorDest, OpenSceneMode.Single);
// Content with VerticalLayoutGroup + ContentSizeFitter
var contentGO = new GameObject("Content");
contentGO.transform.SetParent(vpGO.transform, false);
var contentRect = contentGO.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.anchoredPosition = Vector2.zero;
contentRect.sizeDelta = Vector2.zero;
// Remove unneeded objects
foreach (var name in new[] { "Logo", "SaberSelect", "SongSelect", "Sabers" })
{
var go = GameObject.Find(name);
if (go != null) Object.DestroyImmediate(go);
}
var vlg = contentGO.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 1.5f;
vlg.padding = new RectOffset(2, 2, 2, 2);
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
vlg.childControlWidth = true;
vlg.childControlHeight = true;
// Repurpose Settings panel
var panel = GameObject.Find("Settings");
if (panel == null) { Debug.LogError("[SceneBuilder] 'Settings' not found."); return; }
panel.name = "SongCreatorPanel";
var csf = contentGO.AddComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
csf.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
// Face forward, position in front of player, larger canvas
var panelRect = panel.GetComponent<RectTransform>();
panelRect.localRotation = Quaternion.identity;
panelRect.localPosition = new Vector3(0f, 1.4f, 3.5f);
panelRect.sizeDelta = new Vector2(180f, 145f);
// ScrollRect component
var sr = scrollGO.AddComponent<ScrollRect>();
sr.content = contentRect;
sr.viewport = vpGO.GetComponent<RectTransform>();
sr.horizontal = false;
sr.vertical = true;
sr.movementType = ScrollRect.MovementType.Clamped;
sr.scrollSensitivity = 30f;
sr.inertia = true;
sr.decelerationRate = 0.135f;
// Clear BG children and rebuild UI
var bg = panel.transform.Find("BG");
if (bg != null)
{
for (int i = bg.childCount - 1; i >= 0; i--)
Object.DestroyImmediate(bg.GetChild(i).gameObject);
}
else
{
var bgGO = new GameObject("BG");
bgGO.transform.SetParent(panel.transform, false);
var bgRect = bgGO.AddComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = bgRect.offsetMax = Vector2.zero;
var bgImg = bgGO.AddComponent<Image>();
bgImg.color = new Color(0.22f, 0.40f, 0.49f, 0.49f);
bg = bgGO.transform;
}
// Loading overlay
loadingOverlay = new GameObject("LoadingOverlay");
loadingOverlay.transform.SetParent(parent, false);
StretchFull(loadingOverlay);
loadingOverlay.AddComponent<Image>().color = new Color(0.10f, 0.18f, 0.22f, 0.92f);
CreateLabel(loadingOverlay.transform, "Text", "Loading...",
Vector2.zero, new Vector2(40f, 10f), 5f, Color.white, TextAlignmentOptions.Center);
// Add SongCreatorManager + dependencies to panel
var manager = panel.AddComponent<SongCreatorManager>();
var uploader = panel.AddComponent<BeatSageUploader>();
var publisher = panel.AddComponent<NasPublisher>();
// Error overlay (hidden by default)
errorOverlay = new GameObject("ErrorOverlay");
errorOverlay.transform.SetParent(parent, false);
StretchFull(errorOverlay);
errorOverlay.AddComponent<Image>().color = new Color(0.10f, 0.18f, 0.22f, 0.92f);
var errLblGO = CreateLabel(errorOverlay.transform, "ErrorText", "",
Vector2.zero, new Vector2(48f, 20f), 4.5f, new Color(1f, 0.5f, 0.5f), TextAlignmentOptions.Center);
errorText = errLblGO.GetComponent<TMP_Text>();
errorOverlay.SetActive(false);
// Build all UI and wire references
BuildSongCreatorUI(bg, manager, uploader, publisher);
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
Debug.Log("[SceneBuilder] ✓ SongCreator scene built: " + SongCreatorDest);
scrollContent = contentRect;
}
// ─────────────────────────────────────────────
// Song Creator UI (canvas 180 × 145)
// X: -90 ~ +90 / Y: -72.5 ~ +72.5
// DetailPanel UI
// Local space: 52.7 × 49 → X: ±26.35, Y: ±24.5
// ─────────────────────────────────────────────
private static void BuildSongCreatorUI(Transform bg,
SongCreatorManager manager, BeatSageUploader uploader, NasPublisher publisher)
private static void BuildDetailPanelUI(Transform parent,
out TMP_Text titleTmp, out TMP_Text artistTmp, out TMP_Text infoTmp,
out Button btnNormal, out Button btnHard, out Button btnExpert, out Button btnExpertPlus,
out Button downloadBtn, out Button deleteBtn, out Button playBtn, out Button closeBtn,
out GameObject progressGroup, out Slider progressSlider, out TMP_Text progressText)
{
var so = new SerializedObject(manager);
// Close button (top-right)
var closeBtnGO = CreateStyledButton(parent, "CloseBtn", "✕",
new Vector2(21f, 20.5f), new Vector2(8f, 7f), 5.5f);
closeBtn = closeBtnGO.GetComponent<Button>();
// ── Title ──
CreateLabel(bg, "Title", "SONG CREATOR", new Vector2(0, 64f), new Vector2(170f, 12f), 11f, Color.white, TextAlignmentOptions.Center);
CreateLabel(bg, "Subtitle", "Create beatmaps and upload to NAS",
new Vector2(0, 55f), new Vector2(170f, 7f), 5f, new Color(1,1,1,0.55f), TextAlignmentOptions.Center);
CreateDivider(bg, "Div0", new Vector2(0, 50f), new Vector2(168f, 0.5f));
// Song info
var titleGO = CreateLabel(parent, "TitleText", "---",
new Vector2(-3f, 18.5f), new Vector2(38f, 8f), 6.5f,
Color.white, TextAlignmentOptions.MidlineLeft);
titleTmp = titleGO.GetComponent<TMP_Text>();
titleTmp.overflowMode = TextOverflowModes.Ellipsis;
// ── Audio Source ──
CreateLabel(bg, "LblAudio", "AUDIO SOURCE", new Vector2(-62f, 44f), new Vector2(40f, 6f), 4.5f, new Color(1,1,1,0.65f));
var artistGO = CreateLabel(parent, "ArtistText", "",
new Vector2(0f, 12f), new Vector2(50f, 6f), 5f,
new Color(1f, 1f, 1f, 0.8f), TextAlignmentOptions.Center);
artistTmp = artistGO.GetComponent<TMP_Text>();
var dropdown = CreateDropdown(bg, "AudioDropdown", new Vector2(-8f, 36f), new Vector2(120f, 9f));
var refreshBtn = CreateStyledButton(bg, "Refresh", new Vector2(70f, 36f), new Vector2(24f, 9f), 4.5f);
var infoGO = CreateLabel(parent, "InfoText", "",
new Vector2(0f, 7f), new Vector2(50f, 5f), 4.2f,
new Color(1f, 1f, 1f, 0.6f), TextAlignmentOptions.Center);
infoTmp = infoGO.GetComponent<TMP_Text>();
var pathHint = CreateLabel(bg, "InputPathHint", "Path: ...", new Vector2(0f, 27f), new Vector2(168f, 6f), 3.8f, new Color(1,1,1,0.4f));
CreateDivider(parent, "Div1", new Vector2(0f, 4f), new Vector2(50f, 0.4f));
CreateDivider(bg, "Div1", new Vector2(0, 22f), new Vector2(168f, 0.5f));
// Difficulty section
CreateLabel(parent, "LblDifficulty", "DIFFICULTY",
new Vector2(-16f, 1.5f), new Vector2(26f, 5f), 4.2f,
new Color(1f, 1f, 1f, 0.65f), TextAlignmentOptions.MidlineLeft);
// ── Add Audio ──
CreateLabel(bg, "LblAdd", "ADD AUDIO", new Vector2(-66f, 17f), new Vector2(34f, 6f), 4.5f, new Color(1,1,1,0.65f));
var btnNormalGO = CreateStyledButton(parent, "BtnNormal", "Normal", new Vector2(-12f, -5f), new Vector2(22f, 7f), 4.5f);
var btnHardGO = CreateStyledButton(parent, "BtnHard", "Hard", new Vector2( 12f, -5f), new Vector2(22f, 7f), 4.5f);
var btnExpertGO = CreateStyledButton(parent, "BtnExpert", "Expert", new Vector2(-12f, -14f), new Vector2(22f, 7f), 4.5f);
var btnExpertPlusGO = CreateStyledButton(parent, "BtnExpertPlus", "Expert+", new Vector2( 12f, -14f), new Vector2(22f, 7f), 4.5f);
var fileBtn = CreateStyledButton(bg, "Browse File", new Vector2(-48f, 9f), new Vector2(44f, 9f), 5f);
var addStatus = CreateLabel(bg, "AddStatusText", "No file selected.", new Vector2(28f, 9f), new Vector2(88f, 9f), 4f, new Color(1,1,1,0.5f));
btnNormal = btnNormalGO .GetComponent<Button>();
btnHard = btnHardGO .GetComponent<Button>();
btnExpert = btnExpertGO .GetComponent<Button>();
btnExpertPlus = btnExpertPlusGO.GetComponent<Button>();
CreateLabel(bg, "LblOr", "— or —", new Vector2(0f, 1f), new Vector2(168f, 6f), 4f, new Color(1,1,1,0.4f), TextAlignmentOptions.Center);
CreateDivider(parent, "Div2", new Vector2(0f, -18.5f), new Vector2(50f, 0.4f));
var urlInput = CreateInputField(bg, "UrlInput", "https://example.com/song.mp3", new Vector2(-16f, -7f), new Vector2(120f, 9f));
var urlDlBtn = CreateStyledButton(bg, "Download", new Vector2(68f, -7f), new Vector2(28f, 9f), 4.5f);
// Action buttons
var downloadBtnGO = CreateStyledButton(parent, "DownloadBtn", "Download",
new Vector2(-6f, -21.5f), new Vector2(34f, 7f), 5f);
var deleteBtnGO = CreateStyledButton(parent, "DeleteBtn", "Delete",
new Vector2(-6f, -21.5f), new Vector2(34f, 7f), 5f);
var playBtnGO = CreateStyledButton(parent, "PlayBtn", "Play",
new Vector2(19f, -21.5f), new Vector2(16f, 7f), 5f);
CreateDivider(bg, "Div2", new Vector2(0, -12f), new Vector2(168f, 0.5f));
downloadBtn = downloadBtnGO.GetComponent<Button>();
deleteBtn = deleteBtnGO .GetComponent<Button>();
playBtn = playBtnGO .GetComponent<Button>();
// ── Metadata ──
CreateLabel(bg, "LblMeta", "METADATA", new Vector2(-67f, -17f), new Vector2(30f, 6f), 4.5f, new Color(1,1,1,0.65f));
// Make delete button red-tinted
var delImg = deleteBtnGO.GetComponent<Image>();
if (delImg != null) delImg.color = new Color(0.9f, 0.3f, 0.3f, 0.3f);
CreateLabel(bg, "LblTitle", "Title", new Vector2(-72f, -25f), new Vector2(18f, 7f), 4.2f, new Color(1,1,1,0.7f));
var titleInput = CreateInputField(bg, "TitleInput", "Song title", new Vector2(14f, -25f), new Vector2(130f, 8f));
// Progress group (hidden by default)
progressGroup = new GameObject("ProgressGroup");
progressGroup.transform.SetParent(parent, false);
SetRect(progressGroup, new Vector2(0f, -21.5f), new Vector2(50f, 7f));
CreateLabel(bg, "LblArtist", "Artist", new Vector2(-72f, -34f), new Vector2(18f, 7f), 4.2f, new Color(1,1,1,0.7f));
var artistInput = CreateInputField(bg, "ArtistInput", "Artist name", new Vector2(14f, -34f), new Vector2(130f, 8f));
var pTextGO = CreateLabel(progressGroup.transform, "ProgressText", "--- 0%",
new Vector2(-13f, 0f), new Vector2(22f, 6f), 4f,
new Color(1f, 1f, 1f, 0.85f), TextAlignmentOptions.MidlineLeft);
progressText = pTextGO.GetComponent<TMP_Text>();
CreateLabel(bg, "LblBpm", "BPM", new Vector2(-72f, -43f), new Vector2(18f, 7f), 4.2f, new Color(1,1,1,0.7f));
var bpmInput = CreateInputField(bg, "BpmInput", "120", new Vector2(14f, -43f), new Vector2(130f, 8f), TMP_InputField.ContentType.DecimalNumber);
progressSlider = CreateSlider(progressGroup.transform, "ProgressSlider",
new Vector2(18f, 0f), new Vector2(18f, 4.5f));
CreateDivider(bg, "Div3", new Vector2(0, -48f), new Vector2(168f, 0.5f));
// ── Difficulty ──
CreateLabel(bg, "LblDiff", "DIFFICULTY", new Vector2(-64f, -53f), new Vector2(36f, 6f), 4.5f, new Color(1,1,1,0.65f));
var group = bg.gameObject.AddComponent<ToggleGroup>();
group.allowSwitchOff = true;
var tNormal = CreateToggle(bg, "ToggleNormal", "Normal", new Vector2(-60f, -61f), group, true);
var tHard = CreateToggle(bg, "ToggleHard", "Hard", new Vector2(-20f, -61f), group, true);
var tExpert = CreateToggle(bg, "ToggleExpert", "Expert", new Vector2( 20f, -61f), group, true);
var tExpertPlus= CreateToggle(bg, "ToggleExpertPlus","Expert+", new Vector2( 60f, -61f), group, true);
// ── Actions ──
var generateBtn = CreateStyledButton(bg, "Create & Upload", new Vector2(-20f, -69f), new Vector2(98f, 10f), 6f);
var backBtn = CreateStyledButton(bg, "Back to Menu", new Vector2( 65f, -69f), new Vector2(36f, 10f), 4.5f);
// ── Progress (hidden by default) ──
var progressGO = new GameObject("ProgressGroup");
progressGO.transform.SetParent(bg, false);
SetRect(progressGO, new Vector2(0f, -69f), new Vector2(170f, 10f));
progressGO.SetActive(false);
var statusTxt = CreateLabel(progressGO.transform, "StatusText", "Ready.",
new Vector2(-10f, 0f), new Vector2(120f, 8f), 4f, new Color(1,1,1,0.8f));
var sliderGO = CreateSlider(progressGO.transform, "ProgressSlider",
new Vector2(65f, 0f), new Vector2(50f, 6f));
// ── Wire SerializeField references ──
so.FindProperty("audioDropdown") .objectReferenceValue = dropdown;
so.FindProperty("refreshBtn") .objectReferenceValue = refreshBtn.GetComponent<Button>();
so.FindProperty("inputPathHint") .objectReferenceValue = pathHint.GetComponent<TMP_Text>();
so.FindProperty("filePickerBtn") .objectReferenceValue = fileBtn.GetComponent<Button>();
so.FindProperty("addStatusText") .objectReferenceValue = addStatus.GetComponent<TMP_Text>();
so.FindProperty("urlInput") .objectReferenceValue = urlInput;
so.FindProperty("urlDownloadBtn") .objectReferenceValue = urlDlBtn.GetComponent<Button>();
so.FindProperty("titleInput") .objectReferenceValue = titleInput;
so.FindProperty("artistInput") .objectReferenceValue = artistInput;
so.FindProperty("bpmInput") .objectReferenceValue = bpmInput;
so.FindProperty("toggleNormal") .objectReferenceValue = tNormal;
so.FindProperty("toggleHard") .objectReferenceValue = tHard;
so.FindProperty("toggleExpert") .objectReferenceValue = tExpert;
so.FindProperty("toggleExpertPlus") .objectReferenceValue = tExpertPlus;
so.FindProperty("generateButton") .objectReferenceValue = generateBtn.GetComponent<Button>();
so.FindProperty("backButton") .objectReferenceValue = backBtn.GetComponent<Button>();
so.FindProperty("progressGroup") .objectReferenceValue = progressGO;
so.FindProperty("statusText") .objectReferenceValue = statusTxt.GetComponent<TMP_Text>();
so.FindProperty("progressSlider") .objectReferenceValue = sliderGO;
so.FindProperty("beatSageUploader") .objectReferenceValue = uploader;
so.FindProperty("nasPublisher") .objectReferenceValue = publisher;
so.ApplyModifiedPropertiesWithoutUndo();
// Back button → Menu scene
var backLoader = backBtn.AddComponent<VRBeats.LoadSceneButton>();
InjectPrivate(backLoader, "button", backBtn.GetComponent<Button>());
InjectPrivate(backLoader, "sceneName", "Menu");
progressGroup.SetActive(false);
}
// ─────────────────────────────────────────────
// Helpers — UI factory
// ─────────────────────────────────────────────
private static GameObject CreateStyledButton(Transform parent, string label,
private static GameObject CreateStyledButton(Transform parent, string goName, string label,
Vector2 pos, Vector2 size, float fontSize)
{
var go = new GameObject(label);
var go = new GameObject(goName);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
@@ -286,9 +321,9 @@ public static class VRBeatSaberSceneBuilder
btn.targetGraphic = img;
var c = btn.colors;
c.normalColor = Color.white;
c.highlightedColor = new Color(0.961f, 0.961f, 0.961f, 1f);
c.pressedColor = new Color(0.784f, 0.784f, 0.784f, 1f);
c.selectedColor = new Color(0.961f, 0.961f, 0.961f, 1f);
c.highlightedColor = new Color(0.96f, 0.96f, 0.96f, 1f);
c.pressedColor = new Color(0.78f, 0.78f, 0.78f, 1f);
c.selectedColor = new Color(0.96f, 0.96f, 0.96f, 1f);
c.fadeDuration = 0.1f;
btn.colors = c;
@@ -304,11 +339,11 @@ public static class VRBeatSaberSceneBuilder
return go;
}
private static GameObject CreateLabel(Transform parent, string name, string text,
private static GameObject CreateLabel(Transform parent, string goName, string text,
Vector2 pos, Vector2 size, float fontSize,
Color? color = null, TextAlignmentOptions align = TextAlignmentOptions.MidlineLeft)
{
var go = new GameObject(name);
var go = new GameObject(goName);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
var tmp = go.AddComponent<TextMeshProUGUI>();
@@ -319,221 +354,26 @@ public static class VRBeatSaberSceneBuilder
return go;
}
private static void CreateDivider(Transform parent, string name, Vector2 pos, Vector2 size)
private static void CreateDivider(Transform parent, string goName, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
var go = new GameObject(goName);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
var img = go.AddComponent<Image>();
img.color = new Color(1f, 1f, 1f, 0.18f);
img.color = new Color(1f, 1f, 1f, 0.18f);
img.raycastTarget = false;
}
private static TMP_Dropdown CreateDropdown(Transform parent, string name, Vector2 pos, Vector2 size)
private static Slider CreateSlider(Transform parent, string goName, Vector2 pos, Vector2 size)
{
// Root
var go = new GameObject(name);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
var bgImg = go.AddComponent<Image>();
bgImg.color = new Color(1f, 1f, 1f, 0.1f);
var dd = go.AddComponent<TMP_Dropdown>();
// Caption label
var lbl = new GameObject("Label");
lbl.transform.SetParent(go.transform, false);
var lblRect = lbl.AddComponent<RectTransform>();
lblRect.anchorMin = Vector2.zero;
lblRect.anchorMax = Vector2.one;
lblRect.offsetMin = new Vector2(4f, 2f);
lblRect.offsetMax = new Vector2(-14f, -2f);
var lblTmp = lbl.AddComponent<TextMeshProUGUI>();
lblTmp.fontSize = 4.5f;
lblTmp.color = Color.white;
lblTmp.alignment = TextAlignmentOptions.MidlineLeft;
dd.captionText = lblTmp;
// Arrow
var arrow = new GameObject("Arrow");
arrow.transform.SetParent(go.transform, false);
SetRect(arrow, new Vector2(size.x * 0.5f - 6f, 0f), new Vector2(8f, 8f));
var arrowTmp = arrow.AddComponent<TextMeshProUGUI>();
arrowTmp.text = "▼";
arrowTmp.fontSize = 4f;
arrowTmp.color = new Color(1f, 1f, 1f, 0.7f);
arrowTmp.alignment = TextAlignmentOptions.Center;
// ── Template (must be inactive) ──────────────────────
var tmpl = new GameObject("Template");
tmpl.transform.SetParent(go.transform, false);
var tmplRect = tmpl.AddComponent<RectTransform>();
tmplRect.anchorMin = new Vector2(0f, 0f);
tmplRect.anchorMax = new Vector2(1f, 0f);
tmplRect.pivot = new Vector2(0.5f, 1f);
tmplRect.anchoredPosition = new Vector2(0f, 2f);
tmplRect.sizeDelta = new Vector2(0f, 60f);
var tmplImg = tmpl.AddComponent<Image>();
tmplImg.color = new Color(0.15f, 0.15f, 0.15f, 0.97f);
var scroll = tmpl.AddComponent<ScrollRect>();
// Viewport
var vp = new GameObject("Viewport");
vp.transform.SetParent(tmpl.transform, false);
var vpRect = vp.AddComponent<RectTransform>();
vpRect.anchorMin = Vector2.zero;
vpRect.anchorMax = Vector2.one;
vpRect.offsetMin = vpRect.offsetMax = Vector2.zero;
vpRect.pivot = new Vector2(0f, 1f);
vp.AddComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
vp.AddComponent<Mask>().showMaskGraphic = false;
// Content
var content = new GameObject("Content");
content.transform.SetParent(vp.transform, false);
var contentRect = content.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.anchoredPosition = Vector2.zero;
contentRect.sizeDelta = new Vector2(0f, 28f);
scroll.content = contentRect;
scroll.viewport = vpRect;
scroll.horizontal = false;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.scrollSensitivity = 10f;
// Item (Toggle) — the row template
var item = new GameObject("Item");
item.transform.SetParent(content.transform, false);
var itemRect = item.AddComponent<RectTransform>();
itemRect.anchorMin = new Vector2(0f, 0.5f);
itemRect.anchorMax = new Vector2(1f, 0.5f);
itemRect.pivot = new Vector2(0.5f, 0.5f);
itemRect.sizeDelta = new Vector2(0f, 9f);
itemRect.anchoredPosition = Vector2.zero;
var itemToggle = item.AddComponent<Toggle>();
var itemBg = item.AddComponent<Image>();
itemBg.color = new Color(1f, 1f, 1f, 0f);
itemToggle.targetGraphic = itemBg;
var tc = itemToggle.colors;
tc.highlightedColor = new Color(0.3f, 0.6f, 1f, 0.4f);
tc.selectedColor = new Color(0.3f, 0.6f, 1f, 0.4f);
itemToggle.colors = tc;
// Item Label
var itemLbl = new GameObject("Item Label");
itemLbl.transform.SetParent(item.transform, false);
var ilRect = itemLbl.AddComponent<RectTransform>();
ilRect.anchorMin = Vector2.zero;
ilRect.anchorMax = Vector2.one;
ilRect.offsetMin = new Vector2(4f, 1f);
ilRect.offsetMax = new Vector2(-4f, -1f);
var itemTmp = itemLbl.AddComponent<TextMeshProUGUI>();
itemTmp.fontSize = 4.5f;
itemTmp.color = Color.white;
itemTmp.alignment = TextAlignmentOptions.MidlineLeft;
dd.itemText = itemTmp;
dd.template = tmplRect;
tmpl.SetActive(false);
return dd;
}
private static TMP_InputField CreateInputField(Transform parent, string name,
string placeholder, Vector2 pos, Vector2 size,
TMP_InputField.ContentType contentType = TMP_InputField.ContentType.Standard)
{
var go = new GameObject(name);
var go = new GameObject(goName);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
var bg = go.AddComponent<Image>();
bg.color = new Color(1f, 1f, 1f, 0.08f);
var area = new GameObject("Text Area");
area.transform.SetParent(go.transform, false);
var ar = area.AddComponent<RectTransform>();
ar.anchorMin = Vector2.zero;
ar.anchorMax = Vector2.one;
ar.offsetMin = new Vector2(3f, 1f);
ar.offsetMax = new Vector2(-3f, -1f);
area.AddComponent<RectMask2D>();
var ph = new GameObject("Placeholder");
ph.transform.SetParent(area.transform, false);
StretchFull(ph);
var phTmp = ph.AddComponent<TextMeshProUGUI>();
phTmp.text = placeholder;
phTmp.fontSize = 4.5f;
phTmp.color = new Color(1f, 1f, 1f, 0.3f);
phTmp.alignment = TextAlignmentOptions.MidlineLeft;
var txt = new GameObject("Text");
txt.transform.SetParent(area.transform, false);
StretchFull(txt);
var txtTmp = txt.AddComponent<TextMeshProUGUI>();
txtTmp.fontSize = 4.5f;
txtTmp.color = Color.white;
txtTmp.alignment = TextAlignmentOptions.MidlineLeft;
var field = go.AddComponent<TMP_InputField>();
field.textComponent = txtTmp;
field.placeholder = phTmp;
field.textViewport = ar;
field.contentType = contentType;
return field;
}
private static Toggle CreateToggle(Transform parent, string name, string label,
Vector2 pos, ToggleGroup group, bool isOn)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
SetRect(go, pos, new Vector2(36f, 8f));
var img = go.AddComponent<Image>();
img.color = new Color(1f, 1f, 1f, 0.1f);
var toggle = go.AddComponent<Toggle>();
toggle.targetGraphic = img;
toggle.group = group;
toggle.isOn = isOn;
var tc = toggle.colors;
tc.normalColor = new Color(1f, 1f, 1f, 0.1f);
tc.highlightedColor = new Color(0.4f, 0.7f, 1f, 0.5f);
tc.pressedColor = new Color(0.2f, 0.5f, 0.9f, 0.8f);
tc.selectedColor = new Color(0.3f, 0.6f, 1f, 0.6f);
toggle.colors = tc;
var lbl = new GameObject("Label");
lbl.transform.SetParent(go.transform, false);
StretchFull(lbl);
var tmp = lbl.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 4f;
tmp.color = Color.white;
tmp.alignment = TextAlignmentOptions.Center;
return toggle;
}
private static Slider CreateSlider(Transform parent, string name, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
SetRect(go, pos, size);
var bg = new GameObject("Background");
bg.transform.SetParent(go.transform, false);
StretchFull(bg);
var bgImg = bg.AddComponent<Image>();
bgImg.color = new Color(1f, 1f, 1f, 0.15f);
var bgGO = new GameObject("Background");
bgGO.transform.SetParent(go.transform, false);
StretchFull(bgGO);
bgGO.AddComponent<Image>().color = new Color(1f, 1f, 1f, 0.15f);
var fillArea = new GameObject("Fill Area");
fillArea.transform.SetParent(go.transform, false);
@@ -542,8 +382,7 @@ public static class VRBeatSaberSceneBuilder
var fill = new GameObject("Fill");
fill.transform.SetParent(fillArea.transform, false);
StretchFull(fill);
var fillImg = fill.AddComponent<Image>();
fillImg.color = new Color(0.3f, 0.8f, 0.3f, 0.9f);
fill.AddComponent<Image>().color = new Color(0.3f, 0.8f, 0.3f, 0.9f);
var slider = go.AddComponent<Slider>();
slider.fillRect = fill.GetComponent<RectTransform>();
@@ -578,12 +417,4 @@ public static class VRBeatSaberSceneBuilder
r.anchorMax = Vector2.one;
r.offsetMin = r.offsetMax = Vector2.zero;
}
private static void InjectPrivate(object target, string fieldName, object value)
{
target.GetType()
.GetField(fieldName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.SetValue(target, value);
}
}
+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
File diff suppressed because it is too large Load Diff