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:
@@ -1,281 +1,316 @@
|
|||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using UnityEditor.SceneManagement;
|
using UnityEditor.SceneManagement;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Rendering;
|
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
|
|
||||||
public static class VRBeatSaberSceneBuilder
|
public static class VRBeatSaberSceneBuilder
|
||||||
{
|
{
|
||||||
private const string MenuScene = "Assets/VRBeatsKit/Scenes/Menu.unity";
|
private const string MenuScene = "Assets/VRBeatsKit/Scenes/Menu.unity";
|
||||||
private const string SongCreatorDest = "Assets/Scenes/SongCreator.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")]
|
[MenuItem("Tools/VRBeatSaber/③ Menu — Rebuild SongSelect Panel")]
|
||||||
public static void FixGraphicsAPI()
|
public static void RebuildSongSelectPanel()
|
||||||
{
|
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
var scene = EditorSceneManager.OpenScene(MenuScene, OpenSceneMode.Single);
|
var scene = EditorSceneManager.OpenScene(MenuScene, OpenSceneMode.Single);
|
||||||
|
|
||||||
var settings = GameObject.Find("Settings");
|
var songSelectGO = GameObject.Find("SongSelect");
|
||||||
if (settings == null) { Debug.LogError("[SceneBuilder] 'Settings' not found."); return; }
|
if (songSelectGO == null) { Debug.LogError("[SceneBuilder] 'SongSelect' not found."); return; }
|
||||||
|
|
||||||
if (settings.transform.Find("BG/Song Creator") != null)
|
var bgTransform = songSelectGO.transform.Find("BG");
|
||||||
{ Debug.LogWarning("[SceneBuilder] Button already exists."); return; }
|
if (bgTransform == null) { Debug.LogError("[SceneBuilder] 'SongSelect/BG' not found."); return; }
|
||||||
|
|
||||||
var bg = settings.transform.Find("BG");
|
// Clear BG children
|
||||||
if (bg == null) { Debug.LogError("[SceneBuilder] 'Settings/BG' not found."); return; }
|
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);
|
// Create/reuse SongSystem root GO for SongLibrary (must be root for DontDestroyOnLoad)
|
||||||
var loader = btnGO.AddComponent<VRBeats.LoadSceneButton>();
|
var sysGO = GameObject.Find("SongSystem");
|
||||||
InjectPrivate(loader, "button", btnGO.GetComponent<Button>());
|
if (sysGO == null) sysGO = new GameObject("SongSystem");
|
||||||
InjectPrivate(loader, "sceneName", "SongCreator");
|
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.MarkSceneDirty(scene);
|
||||||
EditorSceneManager.SaveScene(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)
|
// ScrollRect (fills parent)
|
||||||
AssetDatabase.DeleteAsset(SongCreatorDest);
|
var scrollGO = new GameObject("Scroll");
|
||||||
|
scrollGO.transform.SetParent(parent, false);
|
||||||
|
StretchFull(scrollGO);
|
||||||
|
|
||||||
if (!AssetDatabase.CopyAsset(MenuScene, SongCreatorDest))
|
// Viewport with Mask
|
||||||
{
|
var vpGO = new GameObject("Viewport");
|
||||||
Debug.LogError("[SceneBuilder] Failed to copy Menu.unity → SongCreator.unity");
|
vpGO.transform.SetParent(scrollGO.transform, false);
|
||||||
return;
|
StretchFull(vpGO);
|
||||||
}
|
var vpImg = vpGO.AddComponent<Image>();
|
||||||
AssetDatabase.Refresh();
|
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
|
var vlg = contentGO.AddComponent<VerticalLayoutGroup>();
|
||||||
foreach (var name in new[] { "Logo", "SaberSelect", "SongSelect", "Sabers" })
|
vlg.spacing = 1.5f;
|
||||||
{
|
vlg.padding = new RectOffset(2, 2, 2, 2);
|
||||||
var go = GameObject.Find(name);
|
vlg.childForceExpandWidth = true;
|
||||||
if (go != null) Object.DestroyImmediate(go);
|
vlg.childForceExpandHeight = false;
|
||||||
}
|
vlg.childControlWidth = true;
|
||||||
|
vlg.childControlHeight = true;
|
||||||
|
|
||||||
// Repurpose Settings panel
|
var csf = contentGO.AddComponent<ContentSizeFitter>();
|
||||||
var panel = GameObject.Find("Settings");
|
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||||
if (panel == null) { Debug.LogError("[SceneBuilder] 'Settings' not found."); return; }
|
csf.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
||||||
panel.name = "SongCreatorPanel";
|
|
||||||
|
|
||||||
// Face forward, position in front of player, larger canvas
|
// ScrollRect component
|
||||||
var panelRect = panel.GetComponent<RectTransform>();
|
var sr = scrollGO.AddComponent<ScrollRect>();
|
||||||
panelRect.localRotation = Quaternion.identity;
|
sr.content = contentRect;
|
||||||
panelRect.localPosition = new Vector3(0f, 1.4f, 3.5f);
|
sr.viewport = vpGO.GetComponent<RectTransform>();
|
||||||
panelRect.sizeDelta = new Vector2(180f, 145f);
|
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
|
// Loading overlay
|
||||||
var bg = panel.transform.Find("BG");
|
loadingOverlay = new GameObject("LoadingOverlay");
|
||||||
if (bg != null)
|
loadingOverlay.transform.SetParent(parent, false);
|
||||||
{
|
StretchFull(loadingOverlay);
|
||||||
for (int i = bg.childCount - 1; i >= 0; i--)
|
loadingOverlay.AddComponent<Image>().color = new Color(0.10f, 0.18f, 0.22f, 0.92f);
|
||||||
Object.DestroyImmediate(bg.GetChild(i).gameObject);
|
CreateLabel(loadingOverlay.transform, "Text", "Loading...",
|
||||||
}
|
Vector2.zero, new Vector2(40f, 10f), 5f, Color.white, TextAlignmentOptions.Center);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add SongCreatorManager + dependencies to panel
|
// Error overlay (hidden by default)
|
||||||
var manager = panel.AddComponent<SongCreatorManager>();
|
errorOverlay = new GameObject("ErrorOverlay");
|
||||||
var uploader = panel.AddComponent<BeatSageUploader>();
|
errorOverlay.transform.SetParent(parent, false);
|
||||||
var publisher = panel.AddComponent<NasPublisher>();
|
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
|
scrollContent = contentRect;
|
||||||
BuildSongCreatorUI(bg, manager, uploader, publisher);
|
|
||||||
|
|
||||||
EditorSceneManager.MarkSceneDirty(scene);
|
|
||||||
EditorSceneManager.SaveScene(scene);
|
|
||||||
Debug.Log("[SceneBuilder] ✓ SongCreator scene built: " + SongCreatorDest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Song Creator UI (canvas 180 × 145)
|
// DetailPanel UI
|
||||||
// X: -90 ~ +90 / Y: -72.5 ~ +72.5
|
// 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 ──
|
// Song info
|
||||||
CreateLabel(bg, "Title", "SONG CREATOR", new Vector2(0, 64f), new Vector2(170f, 12f), 11f, Color.white, TextAlignmentOptions.Center);
|
var titleGO = CreateLabel(parent, "TitleText", "---",
|
||||||
CreateLabel(bg, "Subtitle", "Create beatmaps and upload to NAS",
|
new Vector2(-3f, 18.5f), new Vector2(38f, 8f), 6.5f,
|
||||||
new Vector2(0, 55f), new Vector2(170f, 7f), 5f, new Color(1,1,1,0.55f), TextAlignmentOptions.Center);
|
Color.white, TextAlignmentOptions.MidlineLeft);
|
||||||
CreateDivider(bg, "Div0", new Vector2(0, 50f), new Vector2(168f, 0.5f));
|
titleTmp = titleGO.GetComponent<TMP_Text>();
|
||||||
|
titleTmp.overflowMode = TextOverflowModes.Ellipsis;
|
||||||
|
|
||||||
// ── Audio Source ──
|
var artistGO = CreateLabel(parent, "ArtistText", "",
|
||||||
CreateLabel(bg, "LblAudio", "AUDIO SOURCE", new Vector2(-62f, 44f), new Vector2(40f, 6f), 4.5f, new Color(1,1,1,0.65f));
|
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 infoGO = CreateLabel(parent, "InfoText", "",
|
||||||
var refreshBtn = CreateStyledButton(bg, "Refresh", new Vector2(70f, 36f), new Vector2(24f, 9f), 4.5f);
|
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 ──
|
var btnNormalGO = CreateStyledButton(parent, "BtnNormal", "Normal", new Vector2(-12f, -5f), new Vector2(22f, 7f), 4.5f);
|
||||||
CreateLabel(bg, "LblAdd", "ADD AUDIO", new Vector2(-66f, 17f), new Vector2(34f, 6f), 4.5f, new Color(1,1,1,0.65f));
|
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);
|
btnNormal = btnNormalGO .GetComponent<Button>();
|
||||||
var addStatus = CreateLabel(bg, "AddStatusText", "No file selected.", new Vector2(28f, 9f), new Vector2(88f, 9f), 4f, new Color(1,1,1,0.5f));
|
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));
|
// Action buttons
|
||||||
var urlDlBtn = CreateStyledButton(bg, "Download", new Vector2(68f, -7f), new Vector2(28f, 9f), 4.5f);
|
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 ──
|
// Make delete button red-tinted
|
||||||
CreateLabel(bg, "LblMeta", "METADATA", new Vector2(-67f, -17f), new Vector2(30f, 6f), 4.5f, new Color(1,1,1,0.65f));
|
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));
|
// Progress group (hidden by default)
|
||||||
var titleInput = CreateInputField(bg, "TitleInput", "Song title", new Vector2(14f, -25f), new Vector2(130f, 8f));
|
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 pTextGO = CreateLabel(progressGroup.transform, "ProgressText", "--- 0%",
|
||||||
var artistInput = CreateInputField(bg, "ArtistInput", "Artist name", new Vector2(14f, -34f), new Vector2(130f, 8f));
|
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));
|
progressSlider = CreateSlider(progressGroup.transform, "ProgressSlider",
|
||||||
var bpmInput = CreateInputField(bg, "BpmInput", "120", new Vector2(14f, -43f), new Vector2(130f, 8f), TMP_InputField.ContentType.DecimalNumber);
|
new Vector2(18f, 0f), new Vector2(18f, 4.5f));
|
||||||
|
|
||||||
CreateDivider(bg, "Div3", new Vector2(0, -48f), new Vector2(168f, 0.5f));
|
progressGroup.SetActive(false);
|
||||||
|
|
||||||
// ── 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Helpers — UI factory
|
// 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)
|
Vector2 pos, Vector2 size, float fontSize)
|
||||||
{
|
{
|
||||||
var go = new GameObject(label);
|
var go = new GameObject(goName);
|
||||||
go.transform.SetParent(parent, false);
|
go.transform.SetParent(parent, false);
|
||||||
SetRect(go, pos, size);
|
SetRect(go, pos, size);
|
||||||
|
|
||||||
@@ -286,9 +321,9 @@ public static class VRBeatSaberSceneBuilder
|
|||||||
btn.targetGraphic = img;
|
btn.targetGraphic = img;
|
||||||
var c = btn.colors;
|
var c = btn.colors;
|
||||||
c.normalColor = Color.white;
|
c.normalColor = Color.white;
|
||||||
c.highlightedColor = 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.784f, 0.784f, 0.784f, 1f);
|
c.pressedColor = new Color(0.78f, 0.78f, 0.78f, 1f);
|
||||||
c.selectedColor = new Color(0.961f, 0.961f, 0.961f, 1f);
|
c.selectedColor = new Color(0.96f, 0.96f, 0.96f, 1f);
|
||||||
c.fadeDuration = 0.1f;
|
c.fadeDuration = 0.1f;
|
||||||
btn.colors = c;
|
btn.colors = c;
|
||||||
|
|
||||||
@@ -304,11 +339,11 @@ public static class VRBeatSaberSceneBuilder
|
|||||||
return go;
|
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,
|
Vector2 pos, Vector2 size, float fontSize,
|
||||||
Color? color = null, TextAlignmentOptions align = TextAlignmentOptions.MidlineLeft)
|
Color? color = null, TextAlignmentOptions align = TextAlignmentOptions.MidlineLeft)
|
||||||
{
|
{
|
||||||
var go = new GameObject(name);
|
var go = new GameObject(goName);
|
||||||
go.transform.SetParent(parent, false);
|
go.transform.SetParent(parent, false);
|
||||||
SetRect(go, pos, size);
|
SetRect(go, pos, size);
|
||||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||||
@@ -319,221 +354,26 @@ public static class VRBeatSaberSceneBuilder
|
|||||||
return go;
|
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);
|
go.transform.SetParent(parent, false);
|
||||||
SetRect(go, pos, size);
|
SetRect(go, pos, size);
|
||||||
var img = go.AddComponent<Image>();
|
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;
|
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(goName);
|
||||||
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);
|
|
||||||
go.transform.SetParent(parent, false);
|
go.transform.SetParent(parent, false);
|
||||||
SetRect(go, pos, size);
|
SetRect(go, pos, size);
|
||||||
|
|
||||||
var bg = go.AddComponent<Image>();
|
var bgGO = new GameObject("Background");
|
||||||
bg.color = new Color(1f, 1f, 1f, 0.08f);
|
bgGO.transform.SetParent(go.transform, false);
|
||||||
|
StretchFull(bgGO);
|
||||||
var area = new GameObject("Text Area");
|
bgGO.AddComponent<Image>().color = new Color(1f, 1f, 1f, 0.15f);
|
||||||
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 fillArea = new GameObject("Fill Area");
|
var fillArea = new GameObject("Fill Area");
|
||||||
fillArea.transform.SetParent(go.transform, false);
|
fillArea.transform.SetParent(go.transform, false);
|
||||||
@@ -542,8 +382,7 @@ public static class VRBeatSaberSceneBuilder
|
|||||||
var fill = new GameObject("Fill");
|
var fill = new GameObject("Fill");
|
||||||
fill.transform.SetParent(fillArea.transform, false);
|
fill.transform.SetParent(fillArea.transform, false);
|
||||||
StretchFull(fill);
|
StretchFull(fill);
|
||||||
var fillImg = fill.AddComponent<Image>();
|
fill.AddComponent<Image>().color = new Color(0.3f, 0.8f, 0.3f, 0.9f);
|
||||||
fillImg.color = new Color(0.3f, 0.8f, 0.3f, 0.9f);
|
|
||||||
|
|
||||||
var slider = go.AddComponent<Slider>();
|
var slider = go.AddComponent<Slider>();
|
||||||
slider.fillRect = fill.GetComponent<RectTransform>();
|
slider.fillRect = fill.GetComponent<RectTransform>();
|
||||||
@@ -578,12 +417,4 @@ public static class VRBeatSaberSceneBuilder
|
|||||||
r.anchorMax = Vector2.one;
|
r.anchorMax = Vector2.one;
|
||||||
r.offsetMin = r.offsetMax = Vector2.zero;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a8efd2469f7355140ae71425ecc638e0
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d27acdc84ca9a6241894ce7ee9f3c3fa
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 765cf3a9cd9c14943be42e1cee050abd
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46403458ee5537142ad1e0b2ce7d3995
|
||||||
+4848
-123
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@ EditorBuildSettings:
|
|||||||
- enabled: 1
|
- enabled: 1
|
||||||
path: Assets/VRBeatsKit/Scenes/BoxingStyle.unity
|
path: Assets/VRBeatsKit/Scenes/BoxingStyle.unity
|
||||||
guid: 63d51c8fe9633e54bb03cc51d243a0cd
|
guid: 63d51c8fe9633e54bb03cc51d243a0cd
|
||||||
|
- enabled: 1
|
||||||
|
path: Assets/Scenes/SongCreator.unity
|
||||||
|
guid: 09ef50fbbdfb41e4784b699dcffd4c2b
|
||||||
- enabled: 1
|
- enabled: 1
|
||||||
path: Assets/VRBeatsKit/Scenes/SaberStyle.unity
|
path: Assets/VRBeatsKit/Scenes/SaberStyle.unity
|
||||||
guid: bb4ee84e66cce254e9cbf1fdfc136c08
|
guid: bb4ee84e66cce254e9cbf1fdfc136c08
|
||||||
@@ -19,3 +22,4 @@ EditorBuildSettings:
|
|||||||
type: 2}
|
type: 2}
|
||||||
com.unity.xr.management.loader_settings: {fileID: 11400000, guid: 34230ee31c1362b49b1574bacd70986e,
|
com.unity.xr.management.loader_settings: {fileID: 11400000, guid: 34230ee31c1362b49b1574bacd70986e,
|
||||||
type: 2}
|
type: 2}
|
||||||
|
m_UseUCBPForAssetBundles: 0
|
||||||
|
|||||||
Reference in New Issue
Block a user