Compare commits
2 Commits
0cd6d17672
...
legacy
| Author | SHA1 | Date | |
|---|---|---|---|
| 16bc037093 | |||
| c73ff7f412 |
@@ -75,3 +75,7 @@ crashlytics-build.properties
|
||||
/[Aa]ssets/[Ss]treamingAssets/aa/*
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/unity
|
||||
|
||||
# 민감 정보 설정 파일
|
||||
/[Aa]ssets/[Ss]treamingAssets/nas_config.json
|
||||
/[Aa]ssets/[Ss]treamingAssets/nas_config.json.meta
|
||||
@@ -16,6 +16,188 @@ public static class VRBeatSaberSceneBuilder
|
||||
{
|
||||
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/전체 자동 설정 (한 번만 실행)")]
|
||||
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 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 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 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(-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(240, 0), new Vector2(140, 50));
|
||||
var btnNorm = MakeButton("NormalBtn", diffPanel.transform, "Normal", new Vector2(-240, 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 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 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 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 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));
|
||||
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, "artistText", artistTxt);
|
||||
Bind(dso, "infoText", infoTxt);
|
||||
Bind(dso, "btnEasy", btnEasy.GetComponent<Button>());
|
||||
Bind(dso, "btnNormal", btnNorm.GetComponent<Button>());
|
||||
Bind(dso, "btnHard", btnHard.GetComponent<Button>());
|
||||
Bind(dso, "btnExpert", btnExp.GetComponent<Button>());
|
||||
Bind(dso, "btnExpertPlus", btnExpP.GetComponent<Button>());
|
||||
Bind(dso, "downloadButton", dlBtn.GetComponent<Button>());
|
||||
Bind(dso, "deleteButton", delBtn.GetComponent<Button>());
|
||||
Bind(dso, "playButton", playBtn.GetComponent<Button>());
|
||||
Bind(dso, "closeButton", closeBtn.GetComponent<Button>());
|
||||
Bind(dso, "progressGroup", progGroup);
|
||||
Bind(dso, "progressSlider", progSlider);
|
||||
Bind(dso, "progressText", progText);
|
||||
@@ -203,6 +389,7 @@ public static class VRBeatSaberSceneBuilder
|
||||
// SongSelectManager 바인딩
|
||||
var mgr = canvas.AddComponent<SongSelectManager>();
|
||||
var mso = new SerializedObject(mgr);
|
||||
Bind(mso, "backButton", backBtnSS.GetComponent<Button>());
|
||||
Bind(mso, "tabAllBtn", tabAllBtn.GetComponent<Button>());
|
||||
Bind(mso, "tabOwnedBtn", tabOwnedBtn.GetComponent<Button>());
|
||||
Bind(mso, "cardContainer", cardContainer);
|
||||
@@ -236,48 +423,69 @@ public static class VRBeatSaberSceneBuilder
|
||||
var publisher = gm.AddComponent<NasPublisher>();
|
||||
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 refreshBtn = MakeButton("RefreshBtn", audioPanel.transform, "새로고침", new Vector2(255, 0), new Vector2(160, 58));
|
||||
var pathHint = MakeTMP("PathHint", canvas.transform, "", new Vector2(0, 195), new Vector2(860, 28), 13);
|
||||
// 음원 선택 (드롭다운 + 새로고침)
|
||||
var audioPanel = MakePanel("AudioPanel", canvas.transform, new Vector2(0, 280), new Vector2(860, 80));
|
||||
var audioDd = MakeDropdown("AudioDropdown", audioPanel.transform, new Vector2(-98, 0), new Vector2(540, 55));
|
||||
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));
|
||||
var titleInput = MakeInputField("TitleInput", metaPanel.transform, "곡 제목", new Vector2(-280, 0), new Vector2(255, 55));
|
||||
var artistInput = MakeInputField("ArtistInput", metaPanel.transform, "아티스트", new Vector2(0, 0), new Vector2(255, 55));
|
||||
var bpmInput = MakeInputField("BpmInput", metaPanel.transform, "BPM", new Vector2(280, 0), new Vector2(255, 55));
|
||||
// 음원 추가 (파일 선택 / URL 다운로드)
|
||||
var addPanel = MakePanel("AddPanel", canvas.transform, new Vector2(0, 155), new Vector2(860, 100));
|
||||
var addStatusTxt = MakeTMP("AddStatusText", addPanel.transform, "", new Vector2(90, 25), new Vector2(530, 36), 13);
|
||||
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 togNormal = MakeToggle("NormalToggle", diffPanel.transform, "Normal", new Vector2(-155, 0));
|
||||
var togHard = MakeToggle("HardToggle", diffPanel.transform, "Hard", new Vector2(0, 0));
|
||||
var togExpert = MakeToggle("ExpertToggle", diffPanel.transform, "Expert", new Vector2(155, 0));
|
||||
// 메타데이터
|
||||
var metaPanel = MakePanel("MetaPanel", canvas.transform, new Vector2(0, 55), new Vector2(860, 80));
|
||||
var titleInput = MakeInputField("TitleInput", metaPanel.transform, "곡 제목", new Vector2(-280, 0), new Vector2(255, 50));
|
||||
var artistInput = MakeInputField("ArtistInput", metaPanel.transform, "아티스트", new Vector2(0, 0), new Vector2(255, 50));
|
||||
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;
|
||||
togExpert.isOn = true;
|
||||
togExpertPlus.isOn = true;
|
||||
|
||||
var actPanel = MakePanel("ActionPanel", canvas.transform, new Vector2(0, -125), new Vector2(860, 80));
|
||||
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 backBtnSC = MakeButton("BackButton", canvas.transform, "< 뒤로", new Vector2(346, 365), new Vector2(130, 50));
|
||||
|
||||
var progGroup = MakePanel("ProgressGroup", canvas.transform, new Vector2(0, -250), new Vector2(860, 95));
|
||||
var statusTxt = MakeTMP("StatusText", progGroup.transform, "", new Vector2(0, 28), new Vector2(840, 36), 18);
|
||||
var progSlider = MakeSlider("ProgressSlider", progGroup.transform, new Vector2(0, -15), new Vector2(820, 28));
|
||||
var actPanel = MakePanel("ActionPanel", canvas.transform, new Vector2(0, -145), new Vector2(860, 75));
|
||||
var genBtn = MakeButton("GenerateButton", actPanel.transform, "AI 생성 시작", new Vector2(-175, 0), new Vector2(440, 60));
|
||||
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);
|
||||
|
||||
var sso = new SerializedObject(scMgr);
|
||||
Bind(sso, "audioDropdown", audioDd);
|
||||
Bind(sso, "refreshBtn", refreshBtn.GetComponent<Button>());
|
||||
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, "artistInput", artistInput);
|
||||
Bind(sso, "bpmInput", bpmInput);
|
||||
Bind(sso, "toggleEasy", togEasy);
|
||||
Bind(sso, "toggleNormal", togNormal);
|
||||
Bind(sso, "toggleHard", togHard);
|
||||
Bind(sso, "toggleExpert", togExpert);
|
||||
Bind(sso, "toggleExpertPlus", togExpertPlus);
|
||||
Bind(sso, "backButton", backBtnSC.GetComponent<Button>());
|
||||
Bind(sso, "generateButton", genBtn.GetComponent<Button>());
|
||||
Bind(sso, "manualEditorButton", manualBtn.GetComponent<Button>());
|
||||
Bind(sso, "progressGroup", progGroup);
|
||||
@@ -438,7 +646,9 @@ public static class VRBeatSaberSceneBuilder
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
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>();
|
||||
rt.anchoredPosition = pos;
|
||||
rt.sizeDelta = size;
|
||||
@@ -449,7 +659,9 @@ public static class VRBeatSaberSceneBuilder
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
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>();
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = pos;
|
||||
@@ -472,7 +684,7 @@ public static class VRBeatSaberSceneBuilder
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fs;
|
||||
tmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = pos;
|
||||
rt.sizeDelta = size;
|
||||
@@ -483,7 +695,9 @@ public static class VRBeatSaberSceneBuilder
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
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 rt = go.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = pos;
|
||||
@@ -517,7 +731,9 @@ public static class VRBeatSaberSceneBuilder
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
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 rt = go.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = pos;
|
||||
|
||||
@@ -20,21 +20,22 @@ public class BeatSageUploader : MonoBehaviour
|
||||
private const float POLL_TIMEOUT = 300f;
|
||||
|
||||
// Beat Sage 난이도 이름 매핑 (내부 → API)
|
||||
// Beat Sage API가 인정하는 난이도: Normal, Hard, Expert, ExpertPlus (Easy 없음)
|
||||
private static readonly Dictionary<string, string> DiffNames = new()
|
||||
{
|
||||
{ "easy", "Easy" },
|
||||
{ "normal", "Normal" },
|
||||
{ "hard", "Hard" },
|
||||
{ "expert", "Expert" },
|
||||
{ "expertplus", "ExpertPlus" },
|
||||
};
|
||||
|
||||
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
|
||||
private static readonly Dictionary<string, string> DatFileNames = new()
|
||||
{
|
||||
{ "easy", "Easy.dat" },
|
||||
{ "normal", "Normal.dat" },
|
||||
{ "hard", "Hard.dat" },
|
||||
{ "expert", "Expert.dat" },
|
||||
{ "expertplus", "ExpertPlus.dat" },
|
||||
};
|
||||
|
||||
public string CurrentStatus { get; private set; } = "";
|
||||
@@ -50,18 +51,20 @@ public class BeatSageUploader : MonoBehaviour
|
||||
Action<string> onError)
|
||||
{
|
||||
// 1단계: 레벨 생성 요청
|
||||
SetStatus("Beat Sage에 음원 전송 중...");
|
||||
SetStatus("[1/4] 음원 업로드 중...");
|
||||
Debug.Log($"[BeatSage] 업로드 시작 — 파일: {audioPath}");
|
||||
string levelId = null;
|
||||
|
||||
yield return CreateLevel(audioPath, difficulties,
|
||||
id => levelId = id,
|
||||
onError);
|
||||
|
||||
Debug.Log($"[BeatSage] CreateLevel 완료 — levelId: {levelId}");
|
||||
if (levelId == null) yield break;
|
||||
onProgress?.Invoke(0.15f);
|
||||
|
||||
// 2단계: 생성 완료 폴링
|
||||
SetStatus("Beat Sage AI 맵 생성 중...");
|
||||
SetStatus("[2/4] AI 맵 생성 시작...");
|
||||
bool ready = false;
|
||||
float elapsed = 0f;
|
||||
|
||||
@@ -74,8 +77,9 @@ public class BeatSageUploader : MonoBehaviour
|
||||
yield return PollHeartbeat(levelId,
|
||||
status =>
|
||||
{
|
||||
ready = status == "generated";
|
||||
error = status == "error";
|
||||
ready = string.Equals(status, "generated", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, "done", System.StringComparison.OrdinalIgnoreCase);
|
||||
error = string.Equals(status, "error", System.StringComparison.OrdinalIgnoreCase);
|
||||
},
|
||||
onError);
|
||||
|
||||
@@ -83,13 +87,13 @@ public class BeatSageUploader : MonoBehaviour
|
||||
|
||||
float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT);
|
||||
onProgress?.Invoke(0.15f + progress * 0.6f);
|
||||
SetStatus($"Beat Sage AI 맵 생성 중... ({(int)elapsed}s)");
|
||||
SetStatus($"[2/4] AI 맵 생성 중... {(int)elapsed}초 경과");
|
||||
}
|
||||
|
||||
if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; }
|
||||
|
||||
// 3단계: .zip 다운로드
|
||||
SetStatus("결과 다운로드 중...");
|
||||
SetStatus("[3/4] 결과 다운로드 중...");
|
||||
byte[] zipBytes = null;
|
||||
|
||||
yield return DownloadZip(levelId,
|
||||
@@ -100,7 +104,7 @@ public class BeatSageUploader : MonoBehaviour
|
||||
onProgress?.Invoke(0.9f);
|
||||
|
||||
// 4단계: .zip 해제 + BeatSageConverter 변환
|
||||
SetStatus("맵 데이터 변환 중...");
|
||||
SetStatus("[3/4] 맵 데이터 변환 중...");
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
|
||||
try
|
||||
@@ -114,7 +118,7 @@ public class BeatSageUploader : MonoBehaviour
|
||||
}
|
||||
|
||||
onProgress?.Invoke(1f);
|
||||
SetStatus("변환 완료");
|
||||
SetStatus("[3/4] 변환 완료");
|
||||
onComplete?.Invoke(maps);
|
||||
}
|
||||
|
||||
@@ -126,9 +130,19 @@ public class BeatSageUploader : MonoBehaviour
|
||||
byte[] audioBytes = File.ReadAllBytes(audioPath);
|
||||
string fileName = Path.GetFileName(audioPath);
|
||||
|
||||
// 난이도 문자열 변환: ["easy","hard"] → "Easy,Hard"
|
||||
var diffStr = string.Join(",", difficulties.ConvertAll(d =>
|
||||
DiffNames.TryGetValue(d, out var n) ? n : d));
|
||||
// 난이도: 알 수 없는 값(easy 등)은 건너뜀, 쉼표 구분 단일 필드로 전송
|
||||
var mappedDiffs = new List<string>();
|
||||
foreach (string d in difficulties)
|
||||
if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n);
|
||||
|
||||
if (mappedDiffs.Count == 0)
|
||||
{
|
||||
onError?.Invoke("Beat Sage가 지원하지 않는 난이도입니다. Normal/Hard/Expert/ExpertPlus 중 선택하세요.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string diffStr = string.Join(",", mappedDiffs);
|
||||
Debug.Log($"[BeatSage] 전송 difficulties: '{diffStr}'");
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
{
|
||||
@@ -149,6 +163,8 @@ public class BeatSageUploader : MonoBehaviour
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
string body = req.downloadHandler?.text ?? "(응답 없음)";
|
||||
Debug.LogError($"[BeatSage] HTTP {req.responseCode} — {req.error}\n응답 본문: {body}");
|
||||
onError?.Invoke($"레벨 생성 요청 실패: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 에디터/PC 환경 전용 테스트 보조 스크립트.
|
||||
/// RuntimeInitializeOnLoadMethod로 자동 생성되므로 씬에 직접 추가할 필요 없습니다.
|
||||
/// Quest 빌드 시 자동으로 비활성화됩니다.
|
||||
///
|
||||
/// 기능:
|
||||
/// 1. TrackedDeviceGraphicRaycaster → GraphicRaycaster 교체 (마우스 클릭 활성화)
|
||||
/// 2. Canvas EventCamera 자동 갱신 (클릭 위치 정확도)
|
||||
/// 3. ESC 키로 씬별 뒤로가기
|
||||
/// </summary>
|
||||
public class DesktopUIMode : MonoBehaviour
|
||||
{
|
||||
#if !UNITY_ANDROID || UNITY_EDITOR
|
||||
|
||||
// 어떤 씬에서 Play를 눌러도 자동 실행
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoCreate()
|
||||
{
|
||||
if (FindObjectOfType<DesktopUIMode>() != null) return; // 이미 있으면 생성 안 함
|
||||
|
||||
var go = new GameObject("[DesktopUIMode]");
|
||||
go.AddComponent<DesktopUIMode>();
|
||||
}
|
||||
|
||||
// ESC 뒤로가기 씬 매핑
|
||||
private static readonly System.Collections.Generic.Dictionary<string, string> BackSceneMap =
|
||||
new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
{ "SongSelect", "Intro" },
|
||||
{ "SongCreator", "Intro" },
|
||||
{ "MapEditorScene", "SongCreator" },
|
||||
{ "Game", "SongSelect" },
|
||||
};
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 중복 방지 (씬에 직접 놓은 경우 AutoCreate와 겹칠 수 있음)
|
||||
if (FindObjectsOfType<DesktopUIMode>().Length > 1)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
DontDestroyOnLoad(gameObject);
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
PatchCanvases(); // 현재 씬 즉시 패치
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
StartCoroutine(PatchAfterFrame());
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator PatchAfterFrame()
|
||||
{
|
||||
yield return null; // Canvas.Awake 이후 실행 보장
|
||||
PatchCanvases();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
RefreshCanvasCameras(); // 매 프레임 worldCamera 갱신 (카메라 초기화 타이밍 보정)
|
||||
|
||||
if (Keyboard.current?.escapeKey.wasPressedThisFrame == true)
|
||||
GoBack();
|
||||
}
|
||||
|
||||
// ── Canvas 패치 ──────────────────────────────────────────
|
||||
|
||||
private static void PatchCanvases()
|
||||
{
|
||||
foreach (var canvas in FindObjectsOfType<Canvas>())
|
||||
{
|
||||
if (canvas.renderMode != RenderMode.WorldSpace) continue;
|
||||
|
||||
// TrackedDeviceGraphicRaycaster → GraphicRaycaster
|
||||
var tracked = canvas.GetComponent("TrackedDeviceGraphicRaycaster");
|
||||
if (tracked != null)
|
||||
{
|
||||
DestroyImmediate(tracked);
|
||||
if (canvas.GetComponent<GraphicRaycaster>() == null)
|
||||
canvas.gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
Debug.Log($"[DesktopUIMode] {canvas.name} Raycaster 교체 완료");
|
||||
}
|
||||
}
|
||||
|
||||
RemoveDuplicateAudioListeners();
|
||||
RefreshCanvasCameras();
|
||||
}
|
||||
|
||||
private static void RemoveDuplicateAudioListeners()
|
||||
{
|
||||
var listeners = FindObjectsOfType<AudioListener>();
|
||||
if (listeners.Length <= 1) return;
|
||||
|
||||
// 첫 번째(DontDestroyOnLoad에 없는 것 우선)만 남기고 나머지 제거
|
||||
AudioListener keep = null;
|
||||
foreach (var al in listeners)
|
||||
{
|
||||
if (al.gameObject.scene.name != "DontDestroyOnLoad")
|
||||
{ keep = al; break; }
|
||||
}
|
||||
if (keep == null) keep = listeners[0];
|
||||
|
||||
foreach (var al in listeners)
|
||||
{
|
||||
if (al != keep)
|
||||
{
|
||||
Debug.Log($"[DesktopUIMode] 중복 AudioListener 제거: {al.gameObject.name}");
|
||||
DestroyImmediate(al);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RefreshCanvasCameras()
|
||||
{
|
||||
Camera cam = Camera.main;
|
||||
if (cam == null)
|
||||
{
|
||||
// Camera.main이 없으면 씬에서 직접 찾기 (DontDestroyOnLoad 제외)
|
||||
foreach (var c in FindObjectsOfType<Camera>())
|
||||
{
|
||||
if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad")
|
||||
{
|
||||
cam = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cam == null) cam = FindObjectOfType<Camera>(); // 최후 수단
|
||||
if (cam == null) return;
|
||||
|
||||
foreach (var canvas in FindObjectsOfType<Canvas>())
|
||||
{
|
||||
if (canvas.renderMode == RenderMode.WorldSpace && canvas.worldCamera != cam)
|
||||
canvas.worldCamera = cam;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESC 뒤로가기 ─────────────────────────────────────────
|
||||
|
||||
private static void GoBack()
|
||||
{
|
||||
string current = SceneManager.GetActiveScene().name;
|
||||
if (BackSceneMap.TryGetValue(current, out string target))
|
||||
SceneManager.LoadScene(target);
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c063d20f87d41d40a6a01c6bd1a1736
|
||||
@@ -6,7 +6,7 @@ using UnityEngine.Networking;
|
||||
|
||||
public class DownloadManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string baseUrl = "http://whdwo798.synology.me:8180/beatsaber";
|
||||
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
|
||||
@@ -64,8 +64,10 @@ public class DownloadManager : MonoBehaviour
|
||||
public string MapPath(SongInfo song, string difficulty)
|
||||
{
|
||||
DifficultyInfo info = song.difficulties.Get(difficulty);
|
||||
if (info == null) return null;
|
||||
return Path.Combine(SongDir(song.id), Path.GetFileName(info.mapFile));
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 내부 구현 ─────────────────────────────────────────────
|
||||
@@ -73,10 +75,11 @@ public class DownloadManager : MonoBehaviour
|
||||
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
||||
Action<float> onProgress, Action onComplete, Action<string> onError)
|
||||
{
|
||||
Directory.CreateDirectory(SongDir(song.id));
|
||||
string songDir = Path.GetFullPath(SongDir(song.id));
|
||||
Directory.CreateDirectory(songDir);
|
||||
|
||||
// 1단계: 오디오 (70%)
|
||||
string audioPath = AudioPath(song.id);
|
||||
string audioPath = Path.Combine(songDir, $"{song.id}.mp3");
|
||||
if (!File.Exists(audioPath))
|
||||
{
|
||||
bool failed = false;
|
||||
@@ -95,7 +98,19 @@ public class DownloadManager : MonoBehaviour
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Game 씬의 뒤로가기 버튼에 자동으로 추가됩니다.
|
||||
/// SceneBuilder가 Button에 이 컴포넌트를 달아 씬 이동을 처리합니다.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Button))]
|
||||
public class GameBackButton : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string targetScene = "SongSelect";
|
||||
|
||||
private void Start()
|
||||
{
|
||||
GetComponent<Button>().onClick.AddListener(() => SceneManager.LoadScene(targetScene));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72739d64e582ca840a355a30eba85d75
|
||||
@@ -10,15 +10,36 @@ using UnityEngine.Networking;
|
||||
public class NasPublisher : MonoBehaviour
|
||||
{
|
||||
[Header("NAS 접속 정보")]
|
||||
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000"; // DSM 포트 (내부망)
|
||||
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000";
|
||||
[SerializeField] private string nasAccount = "admin";
|
||||
[SerializeField] private string nasPassword = ""; // Inspector에서 입력
|
||||
[SerializeField] private string nasRootPath = "/web/beatsaber"; // NAS 내 절대경로
|
||||
[SerializeField] private string nasRootPath = "/web/beatsaber";
|
||||
|
||||
[Header("정적 서버 URL (songs.json 읽기용)")]
|
||||
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me:8180/beatsaber";
|
||||
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private string _sid = ""; // DSM 세션 ID
|
||||
private string _sid = "";
|
||||
private string _synoToken = ""; // DSM 7 CSRF 토큰 (enable_syno_token=yes 로 획득)
|
||||
private string _password = ""; // StreamingAssets/nas_config.json 에서 로드
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
LoadConfig();
|
||||
}
|
||||
|
||||
private void LoadConfig()
|
||||
{
|
||||
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Debug.LogWarning("[NasPublisher] nas_config.json 없음: " + path);
|
||||
return;
|
||||
}
|
||||
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
||||
_password = cfg?.password ?? "";
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class NasConfig { public string password; }
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
@@ -30,17 +51,17 @@ public class NasPublisher : MonoBehaviour
|
||||
Action onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
bool failed = false;
|
||||
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
||||
|
||||
// 1단계: DSM 로그인
|
||||
yield return Login(onError);
|
||||
yield return Login(OnErr);
|
||||
if (string.IsNullOrEmpty(_sid)) yield break;
|
||||
onProgress?.Invoke(0.1f);
|
||||
|
||||
// 2단계: 오디오 업로드
|
||||
yield return UploadFile(
|
||||
audioPath,
|
||||
$"{nasRootPath}/music",
|
||||
$"{song.id}.mp3",
|
||||
onError);
|
||||
yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
onProgress?.Invoke(0.4f);
|
||||
|
||||
// 3단계: 각 난이도 맵 JSON 업로드
|
||||
@@ -53,20 +74,18 @@ public class NasPublisher : MonoBehaviour
|
||||
string json = BeatSageConverter.ToMapJson(kv.Value);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// 파일명에 맞춰 DifficultyInfo 업데이트
|
||||
AssignMapFile(song, kv.Key, fileName);
|
||||
|
||||
yield return UploadBytes(
|
||||
bytes, fileName,
|
||||
$"{nasRootPath}/maps",
|
||||
onError);
|
||||
yield return UploadBytes(bytes, fileName, $"{nasRootPath}/maps", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
|
||||
done++;
|
||||
onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
|
||||
}
|
||||
|
||||
// 4단계: songs.json 다운로드 → 항목 추가 → 재업로드
|
||||
yield return PatchSongsJson(song, onError);
|
||||
yield return PatchSongsJson(song, OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
onProgress?.Invoke(0.95f);
|
||||
|
||||
// 5단계: 로그아웃
|
||||
@@ -81,11 +100,12 @@ public class NasPublisher : MonoBehaviour
|
||||
|
||||
private IEnumerator Login(Action<string> onError)
|
||||
{
|
||||
Debug.Log($"[NasPublisher] 로그인 시도 — nasBaseUrl: '{nasBaseUrl}'");
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=3&method=login" +
|
||||
$"?api=SYNO.API.Auth&version=6&method=login" +
|
||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||
$"&passwd={UnityWebRequest.EscapeURL(nasPassword)}" +
|
||||
$"&session=FileStation&format=sid";
|
||||
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
||||
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
@@ -96,7 +116,11 @@ public class NasPublisher : MonoBehaviour
|
||||
yield break;
|
||||
}
|
||||
|
||||
_sid = ParseSid(req.downloadHandler.text);
|
||||
Debug.Log($"[NasPublisher] 로그인 응답: {req.downloadHandler.text}");
|
||||
string resp = req.downloadHandler.text;
|
||||
_sid = ParseJsonString(resp, "sid");
|
||||
_synoToken = ParseJsonString(resp, "synotoken");
|
||||
Debug.Log($"[NasPublisher] sid={_sid}, synotoken={_synoToken}");
|
||||
if (string.IsNullOrEmpty(_sid))
|
||||
onError?.Invoke("DSM sid 파싱 실패 — 계정 정보를 확인하세요");
|
||||
}
|
||||
@@ -122,26 +146,61 @@ public class NasPublisher : MonoBehaviour
|
||||
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
||||
string nasFolder, Action<string> onError)
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/entry.cgi";
|
||||
Debug.Log($"[NasPublisher] 업로드 시도 — path: '{nasFolder}', file: '{fileName}'");
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
||||
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
||||
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
||||
|
||||
// PowerShell 테스트와 동일한 방식으로 multipart body 수동 구성
|
||||
string boundary = System.Guid.NewGuid().ToString("N");
|
||||
const string CRLF = "\r\n";
|
||||
|
||||
using var bodyStream = new MemoryStream();
|
||||
|
||||
void WriteText(string text)
|
||||
{
|
||||
new MultipartFormDataSection("api", "SYNO.FileStation.Upload"),
|
||||
new MultipartFormDataSection("version", "2"),
|
||||
new MultipartFormDataSection("method", "upload"),
|
||||
new MultipartFormDataSection("path", nasFolder),
|
||||
new MultipartFormDataSection("overwrite", "true"),
|
||||
new MultipartFormDataSection("_sid", _sid),
|
||||
new MultipartFormFileSection("file", bytes, fileName, "application/octet-stream"),
|
||||
};
|
||||
var b = Encoding.UTF8.GetBytes(text);
|
||||
bodyStream.Write(b, 0, b.Length);
|
||||
}
|
||||
void WriteField(string name, string value)
|
||||
{
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}");
|
||||
WriteText(value);
|
||||
WriteText(CRLF);
|
||||
}
|
||||
|
||||
WriteField("path", nasFolder);
|
||||
WriteField("create_parents", "true");
|
||||
WriteField("overwrite", "true");
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}");
|
||||
WriteText($"Content-Type: application/octet-stream{CRLF}{CRLF}");
|
||||
bodyStream.Write(bytes, 0, bytes.Length);
|
||||
WriteText(CRLF);
|
||||
WriteText($"--{boundary}--{CRLF}");
|
||||
|
||||
byte[] bodyBytes = bodyStream.ToArray();
|
||||
|
||||
using var req = new UnityWebRequest(uploadUrl, "POST");
|
||||
req.uploadHandler = new UploadHandlerRaw(bodyBytes);
|
||||
req.downloadHandler = new DownloadHandlerBuffer();
|
||||
req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
|
||||
if (!string.IsNullOrEmpty(_synoToken))
|
||||
req.SetRequestHeader("X-SYNO-TOKEN", _synoToken);
|
||||
|
||||
using var req = UnityWebRequest.Post(url, form);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"업로드 실패({fileName}): {req.error}");
|
||||
else
|
||||
Debug.Log($"[NasPublisher] 업로드 완료: {fileName}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Debug.Log($"[NasPublisher] 업로드 응답({fileName}): {req.downloadHandler.text}");
|
||||
if (req.downloadHandler.text.Contains("\"success\":false"))
|
||||
onError?.Invoke($"업로드 거부({fileName}): {req.downloadHandler.text}");
|
||||
}
|
||||
|
||||
// ── songs.json 패치 ───────────────────────────────────────
|
||||
@@ -178,12 +237,12 @@ public class NasPublisher : MonoBehaviour
|
||||
|
||||
// ── 유틸 ─────────────────────────────────────────────────
|
||||
|
||||
private static string ParseSid(string json)
|
||||
private static string ParseJsonString(string json, string key)
|
||||
{
|
||||
const string key = "\"sid\":\"";
|
||||
int start = json.IndexOf(key, StringComparison.Ordinal);
|
||||
string search = $"\"{key}\":\"";
|
||||
int start = json.IndexOf(search, StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
start += key.Length;
|
||||
start += search.Length;
|
||||
int end = json.IndexOf('"', start);
|
||||
return end > start ? json.Substring(start, end - start) : null;
|
||||
}
|
||||
|
||||
@@ -43,17 +43,17 @@ public class SongInfo
|
||||
[Serializable]
|
||||
public class DifficultyMap
|
||||
{
|
||||
public DifficultyInfo easy;
|
||||
public DifficultyInfo normal;
|
||||
public DifficultyInfo hard;
|
||||
public DifficultyInfo expert;
|
||||
public DifficultyInfo expertplus;
|
||||
|
||||
public DifficultyInfo Get(string key) => key switch
|
||||
{
|
||||
"easy" => easy,
|
||||
"normal" => normal,
|
||||
"hard" => hard,
|
||||
"expert" => expert,
|
||||
"expertplus" => expertplus,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Intro 씬의 XR Origin (Hands)에 추가하세요.
|
||||
/// DontDestroyOnLoad로 모든 씬에 XR Rig를 유지하고,
|
||||
/// 씬 전환 시 중복 카메라를 자동으로 비활성화합니다.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PersistentXRRig : MonoBehaviour
|
||||
{
|
||||
private void Awake()
|
||||
{
|
||||
// 이미 다른 PersistentXRRig가 살아있으면 자기 자신을 제거 (싱글턴)
|
||||
var existing = FindObjectsByType<PersistentXRRig>(FindObjectsSortMode.None);
|
||||
if (existing.Length > 1)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
DontDestroyOnLoad(gameObject);
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
// SceneBuilder가 만든 일반 카메라(Main Camera)를 비활성화
|
||||
// - XR Rig 자식 카메라는 유지
|
||||
foreach (var cam in FindObjectsByType<Camera>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (!cam.transform.IsChildOf(transform))
|
||||
cam.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d94d5e288e7ee34facc8caf424e5c72
|
||||
@@ -2,8 +2,10 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
@@ -12,7 +14,15 @@ public class SongCreatorManager : MonoBehaviour
|
||||
[Header("음원 선택")]
|
||||
[SerializeField] private TMP_Dropdown audioDropdown;
|
||||
[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("메타데이터")]
|
||||
[SerializeField] private TMP_InputField titleInput;
|
||||
@@ -20,14 +30,16 @@ public class SongCreatorManager : MonoBehaviour
|
||||
[SerializeField] private TMP_InputField bpmInput;
|
||||
|
||||
[Header("난이도")]
|
||||
[SerializeField] private Toggle toggleEasy;
|
||||
[SerializeField] private Toggle toggleNormal;
|
||||
[SerializeField] private Toggle toggleHard;
|
||||
[SerializeField] private Toggle toggleExpert;
|
||||
[SerializeField] private Toggle toggleExpertPlus;
|
||||
|
||||
[Header("액션")]
|
||||
[SerializeField] private Button generateButton;
|
||||
[SerializeField] private Button manualEditorButton; // 작은 버튼
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private string introSceneName = "Intro";
|
||||
|
||||
[Header("진행 상태")]
|
||||
[SerializeField] private GameObject progressGroup;
|
||||
@@ -46,6 +58,7 @@ public class SongCreatorManager : MonoBehaviour
|
||||
Path.Combine(Application.persistentDataPath, "input");
|
||||
|
||||
private readonly List<string> audioFiles = new();
|
||||
private string _pendingFilePath; // 파일 다이얼로그 결과 (백그라운드 스레드 → 메인 스레드 전달)
|
||||
|
||||
// ── Unity ────────────────────────────────────────────────
|
||||
|
||||
@@ -56,14 +69,40 @@ public class SongCreatorManager : MonoBehaviour
|
||||
if (inputPathHint != null)
|
||||
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);
|
||||
generateButton.onClick.AddListener(OnGenerateClicked);
|
||||
manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene));
|
||||
backButton?.onClick.AddListener(() => SceneManager.LoadScene(introSceneName));
|
||||
if (filePickerBtn != null) filePickerBtn.onClick.AddListener(OnFilePickerClicked);
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.onClick.AddListener(OnUrlDownloadClicked);
|
||||
|
||||
progressGroup.SetActive(false);
|
||||
RefreshAudioList();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 파일 다이얼로그는 STA 스레드에서 실행되므로 결과를 메인 스레드에서 처리
|
||||
if (_pendingFilePath != null)
|
||||
{
|
||||
CopyToInput(_pendingFilePath);
|
||||
_pendingFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 음원 목록 갱신 ───────────────────────────────────────
|
||||
|
||||
private void RefreshAudioList()
|
||||
@@ -96,14 +135,18 @@ public class SongCreatorManager : MonoBehaviour
|
||||
{ SetStatus("BPM을 올바르게 입력해주세요."); return; }
|
||||
|
||||
var diffs = new List<string>();
|
||||
if (toggleEasy.isOn) diffs.Add("easy");
|
||||
if (toggleNormal.isOn) diffs.Add("normal");
|
||||
if (toggleHard.isOn) diffs.Add("hard");
|
||||
if (toggleExpert.isOn) diffs.Add("expert");
|
||||
if (toggleNormal != null && toggleNormal.isOn) diffs.Add("normal");
|
||||
if (toggleHard != null && toggleHard.isOn) diffs.Add("hard");
|
||||
if (toggleExpert != null && toggleExpert.isOn) diffs.Add("expert");
|
||||
if (toggleExpertPlus != null && toggleExpertPlus.isOn) diffs.Add("expertplus");
|
||||
|
||||
if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; }
|
||||
|
||||
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpm, diffs));
|
||||
string audioPath = audioFiles[audioDropdown.value];
|
||||
Debug.Log($"[SongCreator] 생성 시작 — 파일: {audioPath}, BPM: {bpm}, 난이도: {string.Join(",", diffs)}");
|
||||
Debug.Log($"[SongCreator] beatSageUploader={beatSageUploader}, nasPublisher={nasPublisher}");
|
||||
|
||||
StartCoroutine(GenerateFlow(audioPath, bpm, diffs));
|
||||
}
|
||||
|
||||
// ── 생성 플로우 ───────────────────────────────────────────
|
||||
@@ -112,40 +155,58 @@ public class SongCreatorManager : MonoBehaviour
|
||||
{
|
||||
SetInteractable(false);
|
||||
progressGroup.SetActive(true);
|
||||
Debug.Log("[SongCreator] GenerateFlow 시작");
|
||||
|
||||
// 1단계: Beat Sage 전송 → 변환
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
Debug.Log("[SongCreator] BeatSage Upload 호출");
|
||||
yield return beatSageUploader.Upload(
|
||||
audioPath, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
progressSlider.value = p * 0.8f;
|
||||
SetStatus(beatSageUploader.CurrentStatus);
|
||||
SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Beat Sage 실패: {err}"); failed = true; });
|
||||
onComplete: result =>
|
||||
{
|
||||
maps = result;
|
||||
Debug.Log($"[SongCreator] BeatSage 완료 — 난이도 수: {result?.Count}");
|
||||
},
|
||||
onError: err =>
|
||||
{
|
||||
Debug.LogError($"[SongCreator] BeatSage 오류: {err}");
|
||||
SetStatus($"오류: {err}");
|
||||
failed = true;
|
||||
});
|
||||
|
||||
Debug.Log($"[SongCreator] BeatSage 단계 끝 — failed={failed}, maps={maps?.Count}");
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
// 2단계: NAS 업로드
|
||||
SetStatus("NAS에 업로드 중...");
|
||||
SongInfo song = BuildSongInfo(audioPath, bpm, maps);
|
||||
|
||||
Debug.Log($"[SongCreator] NAS Publish 호출 — song.id={song.id}");
|
||||
yield return nasPublisher.Publish(
|
||||
song, audioPath, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"NAS 업로드 중... {(int)((0.8f + p * 0.2f) * 100)}%");
|
||||
SetStatus($"[4/4] NAS 업로드 중... ({(int)((0.8f + p * 0.2f) * 100)}%)");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
progressSlider.value = 1f;
|
||||
SetStatus($"'{song.title}' 생성 완료!");
|
||||
SetStatus($"완료! '{song.title}' 생성 성공 (100%)");
|
||||
Debug.Log($"[SongCreator] NAS 업로드 완료");
|
||||
},
|
||||
onError: err => { SetStatus($"NAS 업로드 실패: {err}"); failed = true; });
|
||||
onError: err =>
|
||||
{
|
||||
Debug.LogError($"[SongCreator] NAS 오류: {err}");
|
||||
SetStatus($"NAS 업로드 실패: {err}");
|
||||
failed = true;
|
||||
});
|
||||
|
||||
SetInteractable(true);
|
||||
}
|
||||
@@ -163,10 +224,10 @@ public class SongCreatorManager : MonoBehaviour
|
||||
var info = new DifficultyInfo { noteCount = kv.Value.Count };
|
||||
switch (kv.Key)
|
||||
{
|
||||
case "easy": diffMap.easy = info; break;
|
||||
case "normal": diffMap.normal = info; break;
|
||||
case "hard": diffMap.hard = info; break;
|
||||
case "expert": diffMap.expert = info; break;
|
||||
case "expertplus": diffMap.expertplus = info; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,5 +254,102 @@ public class SongCreatorManager : MonoBehaviour
|
||||
manualEditorButton.interactable = value;
|
||||
audioDropdown.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ public class SongDetailPanel : MonoBehaviour
|
||||
[SerializeField] private TMP_Text infoText;
|
||||
|
||||
[Header("난이도 버튼")]
|
||||
[SerializeField] private Button btnEasy;
|
||||
[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;
|
||||
@@ -41,10 +42,10 @@ public class SongDetailPanel : MonoBehaviour
|
||||
|
||||
private readonly (string key, System.Func<SongDetailPanel, Button> btn)[] diffSlots =
|
||||
{
|
||||
("easy", p => p.btnEasy),
|
||||
("normal", p => p.btnNormal),
|
||||
("hard", p => p.btnHard),
|
||||
("expert", p => p.btnExpert),
|
||||
("expertplus", p => p.btnExpertPlus),
|
||||
};
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
@@ -101,6 +102,9 @@ public class SongDetailPanel : MonoBehaviour
|
||||
|
||||
playButton.onClick.RemoveAllListeners();
|
||||
playButton.onClick.AddListener(OnPlayClicked);
|
||||
|
||||
closeButton?.onClick.RemoveAllListeners();
|
||||
closeButton?.onClick.AddListener(() => gameObject.SetActive(false));
|
||||
}
|
||||
|
||||
private void SelectDifficulty(string difficulty)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
@@ -21,6 +22,10 @@ public class Spawner : MonoBehaviour
|
||||
[Header("씬 설정")]
|
||||
public string songSelectSceneName = "SongSelect";
|
||||
|
||||
[Header("뒤로가기 입력")]
|
||||
[Tooltip("Quest B/Y 버튼을 뒤로가기로 쓸 InputAction (선택)")]
|
||||
[SerializeField] private InputActionReference backAction;
|
||||
|
||||
private List<NoteData> mapNotes = new List<NoteData>();
|
||||
private int nextNoteIndex = 0;
|
||||
private float travelTime;
|
||||
@@ -28,6 +33,16 @@ public class Spawner : MonoBehaviour
|
||||
|
||||
// ── Unity ────────────────────────────────────────────────
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// PlayOnAwake가 OnEnable에서 발동하기 전에 차단
|
||||
if (audioSource != null)
|
||||
{
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.clip = null;
|
||||
}
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
travelTime = distanceToHit / noteSpeed;
|
||||
@@ -44,6 +59,10 @@ public class Spawner : MonoBehaviour
|
||||
|
||||
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;
|
||||
|
||||
float currentTime = audioSource.time;
|
||||
@@ -62,25 +81,66 @@ public class Spawner : MonoBehaviour
|
||||
SongInfo song = GameSession.SelectedSong;
|
||||
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);
|
||||
Debug.Log($"[Spawner] 맵 경로: {mapPath}");
|
||||
|
||||
if (string.IsNullOrEmpty(mapPath))
|
||||
{
|
||||
Debug.LogError($"[Spawner] songs.json에 '{difficulty}' mapFile 없음 — NAS 재업로드 필요");
|
||||
yield break;
|
||||
}
|
||||
if (!File.Exists(mapPath))
|
||||
{
|
||||
Debug.LogError($"[Spawner] 맵 파일 없음: {mapPath}");
|
||||
Debug.LogError($"[Spawner] 맵 파일 없음: {mapPath}\n→ SongSelect에서 다운로드하세요");
|
||||
yield break;
|
||||
}
|
||||
LoadMapJson(mapPath);
|
||||
|
||||
// 오디오 로드 (로컬 파일 → AudioClip)
|
||||
// ── 오디오 로드 ──────────────────────────────────────────
|
||||
audioSource.Stop();
|
||||
audioSource.clip = null;
|
||||
|
||||
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);
|
||||
|
||||
// LRU 갱신
|
||||
SongLibrary.Instance?.TouchSong(song.id);
|
||||
if (audioSource.clip == null)
|
||||
{
|
||||
Debug.LogError("[Spawner] 오디오 클립 로드 실패 (파일 손상 또는 형식 오류)");
|
||||
yield break;
|
||||
}
|
||||
|
||||
SongLibrary.Instance?.TouchSong(song.id);
|
||||
isReady = true;
|
||||
audioSource.Play();
|
||||
Debug.Log($"[Spawner] 시작: {song.title} ({difficulty})");
|
||||
Debug.Log($"[Spawner] 재생 시작: {song.title} / {difficulty} / 노트:{mapNotes.Count}개");
|
||||
}
|
||||
|
||||
private void LoadMapJson(string path)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Quest가 아닌 환경(에디터, PC)에서 XR Interaction Simulator를 자동으로 로드합니다.
|
||||
///
|
||||
/// 설정 방법:
|
||||
/// 1. Intro 씬의 XR Origin (또는 임의 GameObject)에 이 스크립트를 추가
|
||||
/// 2. SimulatorPrefab 필드에 아래 경로의 프리팹을 연결:
|
||||
/// Assets/Samples/XR Interaction Toolkit/3.3.1/XR Interaction Simulator/XR Interaction Simulator.prefab
|
||||
///
|
||||
/// 조작법 (XR Interaction Simulator):
|
||||
/// - 마우스 우클릭 드래그 : 머리 방향 회전
|
||||
/// - G 키 누른 채 마우스 이동 : 오른손 컨트롤러 이동
|
||||
/// - Shift+G : 왼손 컨트롤러
|
||||
/// - Space : 트리거(UI 클릭)
|
||||
/// </summary>
|
||||
public class XRSimulatorLoader : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject simulatorPrefab;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
#if !UNITY_ANDROID || UNITY_EDITOR
|
||||
if (simulatorPrefab != null)
|
||||
{
|
||||
Instantiate(simulatorPrefab);
|
||||
Debug.Log("[XRSimulatorLoader] XR Interaction Simulator 시작");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[XRSimulatorLoader] simulatorPrefab이 비어 있습니다.\n" +
|
||||
"Inspector에서 XR Interaction Simulator.prefab을 연결하세요.\n" +
|
||||
"경로: Assets/Samples/XR Interaction Toolkit/3.3.1/XR Interaction Simulator/XR Interaction Simulator.prefab");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e76ce582f56913438ba761e845a91e0
|
||||
Reference in New Issue
Block a user