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);
}
}