UI 수정, 토글 기본값, Spawner 오디오 버그 수정

[SceneBuilder]
- 사용자 수동 조정 위치 반영 (AudioDropdown, RefreshBtn, FilePickerBtn, UrlInput, UrlDownloadBtn, 난이도 토글 4개, BackButton)
- MakeTMP 텍스트 정렬: MidlineLeft → Center
- MakePanel/Button/InputField/Dropdown: sprite = null 설정으로 흰색 배경 제거
- 버튼 텍스트 특수문자 → ASCII 대체 (→ > / ▼▶✕ 제거): NanumGothic SDF 미지원 문자 경고 해결
- 난이도 토글 4개 기본값 모두 true로 변경

[SongCreatorManager]
- Start()에서 토글 전체 꺼진 경우 자동으로 4개 모두 켜기 (씬 재빌드 없이 즉시 적용)

[Spawner]
- Awake(): playOnAwake = false, clip = null 설정 — Inspector 하드코딩 클립 자동 재생 차단
- InitGame(): AudioSource/GameSession null 체크, 파일 존재 확인, 경로 로그 추가
- audioSource.clip == null 후 yield break로 로드 실패 시 재생 방지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:05:04 +09:00
parent c73ff7f412
commit 16bc037093
3 changed files with 456 additions and 47 deletions
+250 -34
View File
@@ -16,6 +16,188 @@ public static class VRBeatSaberSceneBuilder
{ {
private static GameObject s_cardPrefab; private static GameObject s_cardPrefab;
// ══════════════════════════════════════════════════════════
// 뒤로가기 버튼 패치 (기존 씬에 추가)
// ══════════════════════════════════════════════════════════
[MenuItem("Tools/VRBeatSaber/뒤로가기 버튼 추가 (기존 씬 패치)")]
public static void PatchBackButtons()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
// SongSelect: 뒤로(Intro) + DetailPanel 닫기(X)
PatchSceneWithBackButton(
"Assets/Scenes/SongSelect.unity",
() =>
{
FindAndBindBackButton<SongSelectManager>("backButton", "< 뒤로", new Vector2(-500, 310), new Vector2(130, 50));
AddDetailPanelCloseButton();
});
// SongCreator: 뒤로(Intro)
PatchSceneWithBackButton(
"Assets/Scenes/SongCreator.unity",
() => FindAndBindBackButton<SongCreatorManager>("backButton", "< 뒤로", new Vector2(-405, 345), new Vector2(130, 50)));
// MapEditorScene: 뒤로(SongCreator)
PatchSceneWithBackButton(
"Assets/Scenes/MapEditorScene.unity",
FindAndBindBackButtonOnMapEditor);
// Game: 뒤로(SongSelect) — 우측 상단 소형 버튼
PatchSceneWithBackButton(
"Assets/Scenes/Game.unity",
AddGameSceneBackButton);
AssetDatabase.Refresh();
EditorUtility.DisplayDialog("완료", "뒤로가기 버튼 추가 완료!\n\n추가된 버튼:\n- SongSelect: < 뒤로 + 상세패널 X 닫기\n- SongCreator: < 뒤로\n- MapEditorScene: < 뒤로\n- Game: < 뒤로", "확인");
}
private static void PatchSceneWithBackButton(string scenePath, System.Action patchAction)
{
if (!File.Exists(scenePath)) { Debug.LogWarning($"[SceneBuilder] 씬 없음: {scenePath}"); return; }
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
patchAction();
EditorSceneManager.SaveScene(scene);
Debug.Log($"[SceneBuilder] 패치 완료: {scenePath}");
}
private static void FindAndBindBackButton<T>(string fieldName, string label, Vector2 pos, Vector2 size) where T : MonoBehaviour
{
var mgr = Object.FindObjectOfType<T>();
if (mgr == null) { Debug.LogWarning($"[SceneBuilder] {typeof(T).Name} 없음"); return; }
// 이미 있으면 건너뜀
var so = new SerializedObject(mgr);
if (so.FindProperty(fieldName)?.objectReferenceValue != null) return;
// Canvas 찾기
var canvas = Object.FindObjectOfType<Canvas>();
if (canvas == null) return;
var backGO = MakeButton("BackButton", canvas.transform, label, pos, size);
backGO.GetComponent<UnityEngine.UI.Image>().color = new Color(0.18f, 0.18f, 0.18f);
Bind(so, fieldName, backGO.GetComponent<Button>());
so.ApplyModifiedProperties();
}
// SongDetailPanel 닫기(X) 버튼
private static void AddDetailPanelCloseButton()
{
var detail = Object.FindObjectOfType<SongDetailPanel>();
if (detail == null) { Debug.LogWarning("[SceneBuilder] SongDetailPanel 없음"); return; }
var so = new SerializedObject(detail);
if (so.FindProperty("closeButton")?.objectReferenceValue != null) return;
// DetailPanel의 RectTransform 우측 상단에 X 버튼 배치
var detailRT = detail.GetComponent<RectTransform>();
if (detailRT == null) return;
var closeGO = MakeButton("CloseButton", detail.transform, "✕", new Vector2(detailRT.sizeDelta.x / 2f - 30, detailRT.sizeDelta.y / 2f - 30), new Vector2(50, 50));
closeGO.GetComponent<Image>().color = new Color(0.6f, 0.15f, 0.15f);
Bind(so, "closeButton", closeGO.GetComponent<Button>());
so.ApplyModifiedProperties();
}
// Game씬 뒤로가기 버튼 (Canvas 없으면 생성)
private static void AddGameSceneBackButton()
{
var spawner = Object.FindObjectOfType<Spawner>();
if (spawner == null) { Debug.LogWarning("[SceneBuilder] Spawner 없음"); return; }
// Game씬에 Canvas가 있는지 확인, 없으면 생성
var canvas = Object.FindObjectOfType<Canvas>();
if (canvas == null)
{
var canvasGO = new GameObject("HUDCanvas");
var c = canvasGO.AddComponent<Canvas>();
c.renderMode = RenderMode.WorldSpace;
canvasGO.AddComponent<CanvasScaler>();
var trackedType = System.Type.GetType(
"UnityEngine.XR.Interaction.Toolkit.UI.TrackedDeviceGraphicRaycaster, Unity.XR.Interaction.Toolkit");
if (trackedType != null)
canvasGO.AddComponent(trackedType);
else
canvasGO.AddComponent<GraphicRaycaster>();
var rt = canvasGO.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(400, 80);
// 플레이어 앞 위쪽, 항상 보이는 위치
canvasGO.transform.position = new Vector3(0f, 2.2f, 1.5f);
canvasGO.transform.localScale = Vector3.one * 0.002f;
canvas = c;
}
// 이미 BackButton이 있으면 건너뜀
if (canvas.transform.Find("BackButton") != null) return;
var backGO = MakeButton("BackButton", canvas.transform, "< 뒤로", new Vector2(-130, 0), new Vector2(150, 60));
backGO.GetComponent<Image>().color = new Color(0.2f, 0.2f, 0.2f, 0.85f);
// Spawner에 backButton 필드가 없으므로 런타임 클릭 이벤트만 추가
// (Spawner는 Quest B버튼용 InputActionReference 사용, 버튼은 클릭 처리 필요)
// → GameBackButton 컴포넌트로 처리
var handler = backGO.AddComponent<GameBackButton>();
var sso = new SerializedObject(handler);
var targetProp = sso.FindProperty("targetScene");
if (targetProp != null) targetProp.stringValue = "SongSelect";
sso.ApplyModifiedProperties();
}
private static void FindAndBindBackButtonOnMapEditor()
{
var mgr = Object.FindObjectOfType<MapEditor>();
if (mgr == null) { Debug.LogWarning("[SceneBuilder] MapEditor 없음"); return; }
var so = new SerializedObject(mgr);
if (so.FindProperty("backButton")?.objectReferenceValue != null) return;
var canvas = Object.FindObjectOfType<Canvas>();
if (canvas == null)
{
// MapEditorScene에 Canvas가 없을 수 있으므로 직접 생성
var canvasGO = new GameObject("Canvas");
var c = canvasGO.AddComponent<Canvas>();
c.renderMode = RenderMode.WorldSpace;
canvasGO.AddComponent<CanvasScaler>();
canvasGO.AddComponent<GraphicRaycaster>();
var rt = canvasGO.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(200, 60);
canvasGO.transform.position = new Vector3(-0.8f, 1.8f, 2f);
canvasGO.transform.localScale = Vector3.one * 0.002f;
canvas = canvasGO.GetComponent<Canvas>();
}
var backGO = MakeButton("BackButton", canvas.transform, "< 뒤로", new Vector2(0, 0), new Vector2(180, 55));
Bind(so, "backButton", backGO.GetComponent<Button>());
so.ApplyModifiedProperties();
}
[MenuItem("Tools/VRBeatSaber/씬 재빌드 (SongCreator + SongSelect)")]
public static void RebuildCreatorAndSelect()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
EnsureFolder("Assets/Scenes");
EnsureFolder("Assets/Prefab");
s_cardPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefab/SongCard.prefab");
if (s_cardPrefab == null) s_cardPrefab = CreateSongCardPrefab();
BuildSongSelectScene();
BuildSongCreatorScene();
AssetDatabase.Refresh();
EditorUtility.DisplayDialog("완료",
"SongSelect + SongCreator 씬 재빌드 완료!\n\n" +
"Inspector에서 NasPublisher 비밀번호를 확인하세요.",
"확인");
}
[MenuItem("Tools/VRBeatSaber/전체 자동 설정 (한 번만 실행)")] [MenuItem("Tools/VRBeatSaber/전체 자동 설정 (한 번만 실행)")]
public static void SetupAll() public static void SetupAll()
{ {
@@ -140,6 +322,7 @@ public static class VRBeatSaberSceneBuilder
// 탭 // 탭
var tabPanel = MakePanel("TabPanel", canvas.transform, new Vector2(0, 310), new Vector2(1180, 60)); var tabPanel = MakePanel("TabPanel", canvas.transform, new Vector2(0, 310), new Vector2(1180, 60));
var backBtnSS = MakeButton("BackButton", tabPanel.transform, "< 뒤로", new Vector2(-500, 0), new Vector2(130, 50));
var tabAllBtn = MakeButton("TabAllBtn", tabPanel.transform, "전체", new Vector2(-290, 0), new Vector2(200, 50)); var tabAllBtn = MakeButton("TabAllBtn", tabPanel.transform, "전체", new Vector2(-290, 0), new Vector2(200, 50));
var tabOwnedBtn = MakeButton("TabOwnedBtn", tabPanel.transform, "보유중", new Vector2(-80, 0), new Vector2(200, 50)); var tabOwnedBtn = MakeButton("TabOwnedBtn", tabPanel.transform, "보유중", new Vector2(-80, 0), new Vector2(200, 50));
@@ -156,20 +339,22 @@ public static class VRBeatSaberSceneBuilder
var infoTxt = MakeTMP("InfoText", detailGO.transform, "BPM | 길이", new Vector2(0, 160), new Vector2(640, 30), 18); var infoTxt = MakeTMP("InfoText", detailGO.transform, "BPM | 길이", new Vector2(0, 160), new Vector2(640, 30), 18);
var diffPanel = MakePanel("DiffPanel", detailGO.transform, new Vector2(0, 90), new Vector2(640, 60)); var diffPanel = MakePanel("DiffPanel", detailGO.transform, new Vector2(0, 90), new Vector2(640, 60));
var btnEasy = MakeButton("EasyBtn", diffPanel.transform, "Easy", new Vector2(-240, 0), new Vector2(140, 50)); var btnNorm = MakeButton("NormalBtn", diffPanel.transform, "Normal", new Vector2(-240, 0), new Vector2(140, 50));
var btnNorm = MakeButton("NormalBtn", diffPanel.transform, "Normal", new Vector2(-80, 0), new Vector2(140, 50)); var btnHard = MakeButton("HardBtn", diffPanel.transform, "Hard", new Vector2(-80, 0), new Vector2(140, 50));
var btnHard = MakeButton("HardBtn", diffPanel.transform, "Hard", new Vector2(80, 0), new Vector2(140, 50)); var btnExp = MakeButton("ExpertBtn", diffPanel.transform, "Expert", new Vector2(80, 0), new Vector2(140, 50));
var btnExp = MakeButton("ExpertBtn", diffPanel.transform, "Expert", new Vector2(240, 0), new Vector2(140, 50)); var btnExpP = MakeButton("ExpertPlusBtn", diffPanel.transform, "Expert+", new Vector2(240, 0), new Vector2(140, 50));
var actPanel = MakePanel("ActionPanel", detailGO.transform, new Vector2(0, 15), new Vector2(640, 60)); var actPanel = MakePanel("ActionPanel", detailGO.transform, new Vector2(0, 15), new Vector2(640, 60));
var dlBtn = MakeButton("DownloadBtn", actPanel.transform, "다운로드", new Vector2(-165, 0), new Vector2(290, 50)); var dlBtn = MakeButton("DownloadBtn", actPanel.transform, "다운로드", new Vector2(-165, 0), new Vector2(290, 50));
var delBtn = MakeButton("DeleteBtn", actPanel.transform, "삭제", new Vector2(165, 0), new Vector2(290, 50)); var delBtn = MakeButton("DeleteBtn", actPanel.transform, "삭제", new Vector2(165, 0), new Vector2(290, 50));
var progGroup = MakePanel("ProgressGroup", detailGO.transform, new Vector2(0, -50), new Vector2(640, 40)); var progGroup = MakePanel("ProgressGroup", detailGO.transform, new Vector2(0, -50), new Vector2(640, 40));
var progSlider = MakeSlider("ProgressSlider", progGroup.transform, new Vector2(-70, 0), new Vector2(480, 28)); var progSlider = MakeSlider("ProgressSlider", progGroup.transform, new Vector2(-70, 0), new Vector2(480, 28));
var progText = MakeTMP("ProgressText", progGroup.transform, "0%", new Vector2(275, 0), new Vector2(100, 30), 16); var progText = MakeTMP("ProgressText", progGroup.transform, "0%", new Vector2(275, 0), new Vector2(100, 30), 16);
var playBtn = MakeButton("PlayButton", detailGO.transform, "플레이", new Vector2(0, -140), new Vector2(300, 65)); var playBtn = MakeButton("PlayButton", detailGO.transform, "플레이", new Vector2(0, -140), new Vector2(300, 65));
var closeBtn = MakeButton("CloseButton", detailGO.transform, "X", new Vector2(290, 270), new Vector2(50, 50));
closeBtn.GetComponent<Image>().color = new Color(0.6f, 0.15f, 0.15f);
var loadingOvr = MakePanel("LoadingOverlay", canvas.transform, Vector2.zero, new Vector2(1200, 700)); var loadingOvr = MakePanel("LoadingOverlay", canvas.transform, Vector2.zero, new Vector2(1200, 700));
loadingOvr.GetComponent<Image>().color = new Color(0, 0, 0, 0.75f); loadingOvr.GetComponent<Image>().color = new Color(0, 0, 0, 0.75f);
@@ -188,13 +373,14 @@ public static class VRBeatSaberSceneBuilder
Bind(dso, "titleText", titleTxt); Bind(dso, "titleText", titleTxt);
Bind(dso, "artistText", artistTxt); Bind(dso, "artistText", artistTxt);
Bind(dso, "infoText", infoTxt); Bind(dso, "infoText", infoTxt);
Bind(dso, "btnEasy", btnEasy.GetComponent<Button>());
Bind(dso, "btnNormal", btnNorm.GetComponent<Button>()); Bind(dso, "btnNormal", btnNorm.GetComponent<Button>());
Bind(dso, "btnHard", btnHard.GetComponent<Button>()); Bind(dso, "btnHard", btnHard.GetComponent<Button>());
Bind(dso, "btnExpert", btnExp.GetComponent<Button>()); Bind(dso, "btnExpert", btnExp.GetComponent<Button>());
Bind(dso, "btnExpertPlus", btnExpP.GetComponent<Button>());
Bind(dso, "downloadButton", dlBtn.GetComponent<Button>()); Bind(dso, "downloadButton", dlBtn.GetComponent<Button>());
Bind(dso, "deleteButton", delBtn.GetComponent<Button>()); Bind(dso, "deleteButton", delBtn.GetComponent<Button>());
Bind(dso, "playButton", playBtn.GetComponent<Button>()); Bind(dso, "playButton", playBtn.GetComponent<Button>());
Bind(dso, "closeButton", closeBtn.GetComponent<Button>());
Bind(dso, "progressGroup", progGroup); Bind(dso, "progressGroup", progGroup);
Bind(dso, "progressSlider", progSlider); Bind(dso, "progressSlider", progSlider);
Bind(dso, "progressText", progText); Bind(dso, "progressText", progText);
@@ -203,6 +389,7 @@ public static class VRBeatSaberSceneBuilder
// SongSelectManager 바인딩 // SongSelectManager 바인딩
var mgr = canvas.AddComponent<SongSelectManager>(); var mgr = canvas.AddComponent<SongSelectManager>();
var mso = new SerializedObject(mgr); var mso = new SerializedObject(mgr);
Bind(mso, "backButton", backBtnSS.GetComponent<Button>());
Bind(mso, "tabAllBtn", tabAllBtn.GetComponent<Button>()); Bind(mso, "tabAllBtn", tabAllBtn.GetComponent<Button>());
Bind(mso, "tabOwnedBtn", tabOwnedBtn.GetComponent<Button>()); Bind(mso, "tabOwnedBtn", tabOwnedBtn.GetComponent<Button>());
Bind(mso, "cardContainer", cardContainer); Bind(mso, "cardContainer", cardContainer);
@@ -236,48 +423,69 @@ public static class VRBeatSaberSceneBuilder
var publisher = gm.AddComponent<NasPublisher>(); var publisher = gm.AddComponent<NasPublisher>();
var scMgr = gm.AddComponent<SongCreatorManager>(); var scMgr = gm.AddComponent<SongCreatorManager>();
var canvas = MakeCanvas(new Vector2(900, 760), new Vector3(0, 1.5f, 2f)); var canvas = MakeCanvas(new Vector2(900, 800), new Vector3(0, 1.5f, 2f));
MakeTMP("TitleLabel", canvas.transform, "노래 만들기", new Vector2(0, 345), new Vector2(860, 55), 32); MakeTMP("TitleLabel", canvas.transform, "노래 만들기", new Vector2(0, 365), new Vector2(860, 55), 32);
var audioPanel = MakePanel("AudioPanel", canvas.transform, new Vector2(0, 250), new Vector2(860, 90)); // 음원 선택 (드롭다운 + 새로고침)
var audioDd = MakeDropdown("AudioDropdown", audioPanel.transform, new Vector2(-120, 0), new Vector2(540, 60)); var audioPanel = MakePanel("AudioPanel", canvas.transform, new Vector2(0, 280), new Vector2(860, 80));
var refreshBtn = MakeButton("RefreshBtn", audioPanel.transform, "새로고침", new Vector2(255, 0), new Vector2(160, 58)); var audioDd = MakeDropdown("AudioDropdown", audioPanel.transform, new Vector2(-98, 0), new Vector2(540, 55));
var pathHint = MakeTMP("PathHint", canvas.transform, "", new Vector2(0, 195), new Vector2(860, 28), 13); var refreshBtn = MakeButton("RefreshBtn", audioPanel.transform, "새로고침", new Vector2(295, 0), new Vector2(160, 52));
var pathHint = MakeTMP("PathHint", canvas.transform, "", new Vector2(0, 230), new Vector2(860, 26), 12);
pathHint.color = new Color(0.6f, 0.6f, 0.6f);
var metaPanel = MakePanel("MetaPanel", canvas.transform, new Vector2(0, 105), new Vector2(860, 110)); // 음원 추가 (파일 선택 / URL 다운로드)
var titleInput = MakeInputField("TitleInput", metaPanel.transform, "곡 제목", new Vector2(-280, 0), new Vector2(255, 55)); var addPanel = MakePanel("AddPanel", canvas.transform, new Vector2(0, 155), new Vector2(860, 100));
var artistInput = MakeInputField("ArtistInput", metaPanel.transform, "아티스트", new Vector2(0, 0), new Vector2(255, 55)); var addStatusTxt = MakeTMP("AddStatusText", addPanel.transform, "", new Vector2(90, 25), new Vector2(530, 36), 13);
var bpmInput = MakeInputField("BpmInput", metaPanel.transform, "BPM", new Vector2(280, 0), new Vector2(255, 55)); addStatusTxt.color = new Color(0.7f, 0.9f, 0.7f);
var urlField = MakeInputField("UrlInput", addPanel.transform, "MP3 직접 URL 입력", new Vector2(-100, 18), new Vector2(600, 40));
var urlDlBtn = MakeButton("UrlDownloadBtn", addPanel.transform, "URL 다운로드", new Vector2(305, 20), new Vector2(195, 40));
var filePickBtn = MakeButton("FilePickerBtn", addPanel.transform, "파일 선택", new Vector2(304, -29), new Vector2(195, 40));
var diffPanel = MakePanel("DiffPanel", canvas.transform, new Vector2(0, -15), new Vector2(860, 65)); // 메타데이터
var togEasy = MakeToggle("EasyToggle", diffPanel.transform, "Easy", new Vector2(-310, 0)); var metaPanel = MakePanel("MetaPanel", canvas.transform, new Vector2(0, 55), new Vector2(860, 80));
var togNormal = MakeToggle("NormalToggle", diffPanel.transform, "Normal", new Vector2(-155, 0)); var titleInput = MakeInputField("TitleInput", metaPanel.transform, "곡 제목", new Vector2(-280, 0), new Vector2(255, 50));
var togHard = MakeToggle("HardToggle", diffPanel.transform, "Hard", new Vector2(0, 0)); var artistInput = MakeInputField("ArtistInput", metaPanel.transform, "아티스트", new Vector2(0, 0), new Vector2(255, 50));
var togExpert = MakeToggle("ExpertToggle", diffPanel.transform, "Expert", new Vector2(155, 0)); var bpmInput = MakeInputField("BpmInput", metaPanel.transform, "BPM", new Vector2(280, 0), new Vector2(255, 50));
// 난이도 토글 (Easy 제거, ExpertPlus 추가)
var diffPanel = MakePanel("DiffPanel", canvas.transform, new Vector2(0, -45), new Vector2(860, 60));
var togNormal = MakeToggle("NormalToggle", diffPanel.transform, "Normal", new Vector2(-206, 0));
var togHard = MakeToggle("HardToggle", diffPanel.transform, "Hard", new Vector2(-51, 0));
var togExpert = MakeToggle("ExpertToggle", diffPanel.transform, "Expert", new Vector2(104, 0));
var togExpertPlus = MakeToggle("ExpertPlusToggle", diffPanel.transform, "Expert+", new Vector2(259, 0));
togNormal.isOn = true;
togHard.isOn = true; togHard.isOn = true;
togExpert.isOn = true; togExpert.isOn = true;
togExpertPlus.isOn = true;
var actPanel = MakePanel("ActionPanel", canvas.transform, new Vector2(0, -125), new Vector2(860, 80)); var backBtnSC = MakeButton("BackButton", canvas.transform, "< 뒤로", new Vector2(346, 365), new Vector2(130, 50));
var genBtn = MakeButton("GenerateButton", actPanel.transform, "AI 생성 시작", new Vector2(-175, 0), new Vector2(440, 65));
var manualBtn = MakeButton("ManualButton", actPanel.transform, "직접 만들기 →", new Vector2(245, 0), new Vector2(185, 42));
var progGroup = MakePanel("ProgressGroup", canvas.transform, new Vector2(0, -250), new Vector2(860, 95)); var actPanel = MakePanel("ActionPanel", canvas.transform, new Vector2(0, -145), new Vector2(860, 75));
var statusTxt = MakeTMP("StatusText", progGroup.transform, "", new Vector2(0, 28), new Vector2(840, 36), 18); var genBtn = MakeButton("GenerateButton", actPanel.transform, "AI 생성 시작", new Vector2(-175, 0), new Vector2(440, 60));
var progSlider = MakeSlider("ProgressSlider", progGroup.transform, new Vector2(0, -15), new Vector2(820, 28)); var manualBtn = MakeButton("ManualButton", actPanel.transform, "직접 만들기 >", new Vector2(245, 0), new Vector2(185, 40));
var progGroup = MakePanel("ProgressGroup", canvas.transform, new Vector2(0, -265), new Vector2(860, 90));
var statusTxt = MakeTMP("StatusText", progGroup.transform, "", new Vector2(0, 25), new Vector2(840, 34), 17);
var progSlider = MakeSlider("ProgressSlider", progGroup.transform, new Vector2(0, -15), new Vector2(820, 26));
progGroup.SetActive(false); progGroup.SetActive(false);
var sso = new SerializedObject(scMgr); var sso = new SerializedObject(scMgr);
Bind(sso, "audioDropdown", audioDd); Bind(sso, "audioDropdown", audioDd);
Bind(sso, "refreshBtn", refreshBtn.GetComponent<Button>()); Bind(sso, "refreshBtn", refreshBtn.GetComponent<Button>());
Bind(sso, "inputPathHint", pathHint); Bind(sso, "inputPathHint", pathHint);
Bind(sso, "filePickerBtn", filePickBtn.GetComponent<Button>());
Bind(sso, "addStatusText", addStatusTxt);
Bind(sso, "urlInput", urlField);
Bind(sso, "urlDownloadBtn", urlDlBtn.GetComponent<Button>());
Bind(sso, "titleInput", titleInput); Bind(sso, "titleInput", titleInput);
Bind(sso, "artistInput", artistInput); Bind(sso, "artistInput", artistInput);
Bind(sso, "bpmInput", bpmInput); Bind(sso, "bpmInput", bpmInput);
Bind(sso, "toggleEasy", togEasy);
Bind(sso, "toggleNormal", togNormal); Bind(sso, "toggleNormal", togNormal);
Bind(sso, "toggleHard", togHard); Bind(sso, "toggleHard", togHard);
Bind(sso, "toggleExpert", togExpert); Bind(sso, "toggleExpert", togExpert);
Bind(sso, "toggleExpertPlus", togExpertPlus);
Bind(sso, "backButton", backBtnSC.GetComponent<Button>());
Bind(sso, "generateButton", genBtn.GetComponent<Button>()); Bind(sso, "generateButton", genBtn.GetComponent<Button>());
Bind(sso, "manualEditorButton", manualBtn.GetComponent<Button>()); Bind(sso, "manualEditorButton", manualBtn.GetComponent<Button>());
Bind(sso, "progressGroup", progGroup); Bind(sso, "progressGroup", progGroup);
@@ -438,7 +646,9 @@ public static class VRBeatSaberSceneBuilder
{ {
var go = new GameObject(name); var go = new GameObject(name);
go.transform.SetParent(parent, false); go.transform.SetParent(parent, false);
go.AddComponent<Image>().color = new Color(0.08f, 0.08f, 0.08f, 0.88f); var img = go.AddComponent<Image>();
img.color = new Color(0.08f, 0.08f, 0.08f, 0.88f);
img.sprite = null;
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.anchoredPosition = pos;
rt.sizeDelta = size; rt.sizeDelta = size;
@@ -449,7 +659,9 @@ public static class VRBeatSaberSceneBuilder
{ {
var go = new GameObject(name); var go = new GameObject(name);
go.transform.SetParent(parent, false); go.transform.SetParent(parent, false);
go.AddComponent<Image>().color = new Color(0.22f, 0.22f, 0.22f); var img = go.AddComponent<Image>();
img.color = new Color(0.22f, 0.22f, 0.22f);
img.sprite = null;
go.AddComponent<Button>(); go.AddComponent<Button>();
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.anchoredPosition = pos;
@@ -472,7 +684,7 @@ public static class VRBeatSaberSceneBuilder
var tmp = go.AddComponent<TextMeshProUGUI>(); var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text; tmp.text = text;
tmp.fontSize = fs; tmp.fontSize = fs;
tmp.alignment = TextAlignmentOptions.MidlineLeft; tmp.alignment = TextAlignmentOptions.Center;
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.anchoredPosition = pos;
rt.sizeDelta = size; rt.sizeDelta = size;
@@ -483,7 +695,9 @@ public static class VRBeatSaberSceneBuilder
{ {
var go = new GameObject(name); var go = new GameObject(name);
go.transform.SetParent(parent, false); go.transform.SetParent(parent, false);
go.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.15f); var fImg = go.AddComponent<Image>();
fImg.color = new Color(0.15f, 0.15f, 0.15f);
fImg.sprite = null;
var field = go.AddComponent<TMP_InputField>(); var field = go.AddComponent<TMP_InputField>();
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.anchoredPosition = pos;
@@ -517,7 +731,9 @@ public static class VRBeatSaberSceneBuilder
{ {
var go = new GameObject(name); var go = new GameObject(name);
go.transform.SetParent(parent, false); go.transform.SetParent(parent, false);
go.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.15f); var ddImg = go.AddComponent<Image>();
ddImg.color = new Color(0.15f, 0.15f, 0.15f);
ddImg.sprite = null;
var dd = go.AddComponent<TMP_Dropdown>(); var dd = go.AddComponent<TMP_Dropdown>();
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.anchoredPosition = pos;
+138 -5
View File
@@ -2,8 +2,10 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement; using UnityEngine.SceneManagement;
using UnityEngine.UI; using UnityEngine.UI;
@@ -12,7 +14,15 @@ public class SongCreatorManager : MonoBehaviour
[Header("음원 선택")] [Header("음원 선택")]
[SerializeField] private TMP_Dropdown audioDropdown; [SerializeField] private TMP_Dropdown audioDropdown;
[SerializeField] private Button refreshBtn; [SerializeField] private Button refreshBtn;
[SerializeField] private TMP_Text inputPathHint; // 파일 넣는 경로 안내 [SerializeField] private TMP_Text inputPathHint;
[Header("음원 추가 — 로컬 파일")]
[SerializeField] private Button filePickerBtn; // 파일 탐색 버튼
[SerializeField] private TMP_Text addStatusText; // 추가/다운로드 상태
[Header("음원 추가 — URL")]
[SerializeField] private TMP_InputField urlInput; // MP3 직접 URL
[SerializeField] private Button urlDownloadBtn; // URL 다운로드 시작
[Header("메타데이터")] [Header("메타데이터")]
[SerializeField] private TMP_InputField titleInput; [SerializeField] private TMP_InputField titleInput;
@@ -48,6 +58,7 @@ public class SongCreatorManager : MonoBehaviour
Path.Combine(Application.persistentDataPath, "input"); Path.Combine(Application.persistentDataPath, "input");
private readonly List<string> audioFiles = new(); private readonly List<string> audioFiles = new();
private string _pendingFilePath; // 파일 다이얼로그 결과 (백그라운드 스레드 → 메인 스레드 전달)
// ── Unity ──────────────────────────────────────────────── // ── Unity ────────────────────────────────────────────────
@@ -58,15 +69,40 @@ public class SongCreatorManager : MonoBehaviour
if (inputPathHint != null) if (inputPathHint != null)
inputPathHint.text = $"음원 경로: {InputPath}"; inputPathHint.text = $"음원 경로: {InputPath}";
// 씬에서 아무 토글도 안 선택되어 있으면 전부 켜기
bool anyOn = (toggleNormal != null && toggleNormal.isOn)
|| (toggleHard != null && toggleHard.isOn)
|| (toggleExpert != null && toggleExpert.isOn)
|| (toggleExpertPlus != null && toggleExpertPlus.isOn);
if (!anyOn)
{
if (toggleNormal != null) toggleNormal.isOn = true;
if (toggleHard != null) toggleHard.isOn = true;
if (toggleExpert != null) toggleExpert.isOn = true;
if (toggleExpertPlus != null) toggleExpertPlus.isOn = true;
}
refreshBtn.onClick.AddListener(RefreshAudioList); refreshBtn.onClick.AddListener(RefreshAudioList);
generateButton.onClick.AddListener(OnGenerateClicked); generateButton.onClick.AddListener(OnGenerateClicked);
manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene)); manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene));
backButton?.onClick.AddListener(() => SceneManager.LoadScene(introSceneName)); backButton?.onClick.AddListener(() => SceneManager.LoadScene(introSceneName));
if (filePickerBtn != null) filePickerBtn.onClick.AddListener(OnFilePickerClicked);
if (urlDownloadBtn != null) urlDownloadBtn.onClick.AddListener(OnUrlDownloadClicked);
progressGroup.SetActive(false); progressGroup.SetActive(false);
RefreshAudioList(); RefreshAudioList();
} }
private void Update()
{
// 파일 다이얼로그는 STA 스레드에서 실행되므로 결과를 메인 스레드에서 처리
if (_pendingFilePath != null)
{
CopyToInput(_pendingFilePath);
_pendingFilePath = null;
}
}
// ── 음원 목록 갱신 ─────────────────────────────────────── // ── 음원 목록 갱신 ───────────────────────────────────────
private void RefreshAudioList() private void RefreshAudioList()
@@ -99,10 +135,10 @@ public class SongCreatorManager : MonoBehaviour
{ SetStatus("BPM을 올바르게 입력해주세요."); return; } { SetStatus("BPM을 올바르게 입력해주세요."); return; }
var diffs = new List<string>(); var diffs = new List<string>();
if (toggleNormal.isOn) diffs.Add("normal"); if (toggleNormal != null && toggleNormal.isOn) diffs.Add("normal");
if (toggleHard.isOn) diffs.Add("hard"); if (toggleHard != null && toggleHard.isOn) diffs.Add("hard");
if (toggleExpert.isOn) diffs.Add("expert"); if (toggleExpert != null && toggleExpert.isOn) diffs.Add("expert");
if (toggleExpertPlus.isOn) diffs.Add("expertplus"); if (toggleExpertPlus != null && toggleExpertPlus.isOn) diffs.Add("expertplus");
if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; } if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; }
@@ -218,5 +254,102 @@ public class SongCreatorManager : MonoBehaviour
manualEditorButton.interactable = value; manualEditorButton.interactable = value;
audioDropdown.interactable = value; audioDropdown.interactable = value;
refreshBtn.interactable = value; refreshBtn.interactable = value;
if (filePickerBtn != null) filePickerBtn.interactable = value;
if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
}
// ── 로컬 파일 선택 ────────────────────────────────────────
private void OnFilePickerClicked()
{
#if UNITY_EDITOR
string path = UnityEditor.EditorUtility.OpenFilePanel("음원 파일 선택", "", "mp3");
if (!string.IsNullOrEmpty(path)) CopyToInput(path);
#elif UNITY_STANDALONE_WIN
var t = new Thread(() =>
{
var dlg = new System.Windows.Forms.OpenFileDialog
{
Filter = "MP3 파일|*.mp3",
Title = "음원 파일 선택"
};
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
_pendingFilePath = dlg.FileName;
});
t.SetApartmentState(ApartmentState.STA);
t.Start();
#else
SetAddStatus($"ADB로 파일을 추가하세요:\n{InputPath}");
#endif
}
private void CopyToInput(string srcPath)
{
try
{
string dest = Path.Combine(InputPath, Path.GetFileName(srcPath));
File.Copy(srcPath, dest, overwrite: true);
RefreshAudioList();
string nameNoExt = Path.GetFileNameWithoutExtension(srcPath);
int idx = audioFiles.FindIndex(f => Path.GetFileNameWithoutExtension(f) == nameNoExt);
if (idx >= 0) audioDropdown.value = idx;
SetAddStatus($"추가됨: {Path.GetFileName(srcPath)}");
}
catch (Exception e)
{
SetAddStatus($"파일 추가 실패: {e.Message}");
}
}
// ── URL 다운로드 ──────────────────────────────────────────
private void OnUrlDownloadClicked()
{
string url = urlInput != null ? urlInput.text.Trim() : "";
if (string.IsNullOrEmpty(url)) { SetAddStatus("URL을 입력해주세요."); return; }
StartCoroutine(DownloadFromUrl(url));
}
private IEnumerator DownloadFromUrl(string url)
{
SetAddStatus("다운로드 중...");
if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;
string fileName;
try
{
string uriPath = new Uri(url).AbsolutePath;
fileName = Path.GetFileName(uriPath);
if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
fileName = "download.mp3";
}
catch { fileName = "download.mp3"; }
string savePath = Path.GetFullPath(Path.Combine(InputPath, fileName));
using var req = UnityWebRequest.Get(url);
req.downloadHandler = new DownloadHandlerFile(savePath);
yield return req.SendWebRequest();
if (urlDownloadBtn != null) urlDownloadBtn.interactable = true;
if (req.result == UnityWebRequest.Result.Success)
{
RefreshAudioList();
string nameNoExt = Path.GetFileNameWithoutExtension(fileName);
int idx = audioFiles.FindIndex(f => Path.GetFileNameWithoutExtension(f) == nameNoExt);
if (idx >= 0) audioDropdown.value = idx;
SetAddStatus($"다운로드 완료: {fileName}");
}
else
{
if (File.Exists(savePath)) File.Delete(savePath);
SetAddStatus($"다운로드 실패: {req.error}");
}
}
private void SetAddStatus(string msg)
{
if (addStatusText != null) addStatusText.text = msg;
} }
} }
+66 -6
View File
@@ -2,6 +2,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Networking; using UnityEngine.Networking;
using UnityEngine.SceneManagement; using UnityEngine.SceneManagement;
@@ -21,6 +22,10 @@ public class Spawner : MonoBehaviour
[Header("씬 설정")] [Header("씬 설정")]
public string songSelectSceneName = "SongSelect"; public string songSelectSceneName = "SongSelect";
[Header("뒤로가기 입력")]
[Tooltip("Quest B/Y 버튼을 뒤로가기로 쓸 InputAction (선택)")]
[SerializeField] private InputActionReference backAction;
private List<NoteData> mapNotes = new List<NoteData>(); private List<NoteData> mapNotes = new List<NoteData>();
private int nextNoteIndex = 0; private int nextNoteIndex = 0;
private float travelTime; private float travelTime;
@@ -28,6 +33,16 @@ public class Spawner : MonoBehaviour
// ── Unity ──────────────────────────────────────────────── // ── Unity ────────────────────────────────────────────────
void Awake()
{
// PlayOnAwake가 OnEnable에서 발동하기 전에 차단
if (audioSource != null)
{
audioSource.playOnAwake = false;
audioSource.clip = null;
}
}
void Start() void Start()
{ {
travelTime = distanceToHit / noteSpeed; travelTime = distanceToHit / noteSpeed;
@@ -44,6 +59,10 @@ public class Spawner : MonoBehaviour
void Update() void Update()
{ {
// 뒤로가기: Quest B/Y 버튼 (PC ESC는 DesktopUIMode가 처리)
bool goBack = backAction != null && backAction.action.WasPressedThisFrame();
if (goBack) { SceneManager.LoadScene(songSelectSceneName); return; }
if (!isReady || audioSource == null || !audioSource.isPlaying) return; if (!isReady || audioSource == null || !audioSource.isPlaying) return;
float currentTime = audioSource.time; float currentTime = audioSource.time;
@@ -62,25 +81,66 @@ public class Spawner : MonoBehaviour
SongInfo song = GameSession.SelectedSong; SongInfo song = GameSession.SelectedSong;
string difficulty = GameSession.SelectedDifficulty; string difficulty = GameSession.SelectedDifficulty;
// 맵 JSON 로드 // ── 기본 연결 확인 ───────────────────────────────────────
if (audioSource == null)
{
Debug.LogError("[Spawner] AudioSource 미연결 — Inspector에서 AudioSource 연결 필요");
yield break;
}
if (song == null)
{
Debug.LogError("[Spawner] GameSession.SelectedSong == null — SongSelect에서 곡을 선택하고 Play를 눌러야 합니다");
yield break;
}
if (string.IsNullOrEmpty(difficulty))
{
Debug.LogError("[Spawner] GameSession.SelectedDifficulty 비어있음");
yield break;
}
Debug.Log($"[Spawner] 시작: song={song.title}({song.id}), diff={difficulty}");
// ── 맵 JSON 로드 ─────────────────────────────────────────
string mapPath = GetMapPath(song, difficulty); string mapPath = GetMapPath(song, difficulty);
Debug.Log($"[Spawner] 맵 경로: {mapPath}");
if (string.IsNullOrEmpty(mapPath))
{
Debug.LogError($"[Spawner] songs.json에 '{difficulty}' mapFile 없음 — NAS 재업로드 필요");
yield break;
}
if (!File.Exists(mapPath)) if (!File.Exists(mapPath))
{ {
Debug.LogError($"[Spawner] 맵 파일 없음: {mapPath}"); Debug.LogError($"[Spawner] 맵 파일 없음: {mapPath}\n→ SongSelect에서 다운로드하세요");
yield break; yield break;
} }
LoadMapJson(mapPath); LoadMapJson(mapPath);
// 오디오 로드 (로컬 파일 → AudioClip) // ── 오디오 로드 ──────────────────────────────────────────
audioSource.Stop();
audioSource.clip = null;
string audioPath = GetAudioPath(song.id); string audioPath = GetAudioPath(song.id);
Debug.Log($"[Spawner] 오디오 경로: {audioPath}");
if (!File.Exists(audioPath))
{
Debug.LogError($"[Spawner] 오디오 파일 없음: {audioPath}\n→ SongSelect에서 다운로드하세요");
yield break;
}
yield return LoadAudioClip(audioPath); yield return LoadAudioClip(audioPath);
// LRU 갱신 if (audioSource.clip == null)
SongLibrary.Instance?.TouchSong(song.id); {
Debug.LogError("[Spawner] 오디오 클립 로드 실패 (파일 손상 또는 형식 오류)");
yield break;
}
SongLibrary.Instance?.TouchSong(song.id);
isReady = true; isReady = true;
audioSource.Play(); audioSource.Play();
Debug.Log($"[Spawner] 시작: {song.title} ({difficulty})"); Debug.Log($"[Spawner] 재생 시작: {song.title} / {difficulty} / 노트:{mapNotes.Count}개");
} }
private void LoadMapJson(string path) private void LoadMapJson(string path)