Compare commits

2 Commits

Author SHA1 Message Date
jongjae0305 a00ab7e32d 노래찍기 기능추가 및 박스 파괴가 아니라 베이도록 수정 2026-04-30 17:42:25 +09:00
jongjae0305 0b6374511c BeatSaber 게임 모작 및 이그노어등 2026-04-30 14:00:10 +09:00
1614 changed files with 432 additions and 265423 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"Bash(Get-ChildItem -Recurse -Directory)",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(Get-ChildItem -Path \"Assets\" -Recurse -Directory)",
"Bash(git log *)",
"Bash(read f *)",
"Bash(curl -o CLAUDE.md https://raw.githubusercontent.com/forrestchang/andrej-karpathy-skills/main/CLAUDE.md)",
"WebSearch",
"WebFetch(domain:beat-sage.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:beatsage.com)",
"Bash(gh api *)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:api.github.com)"
]
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"recommendations": [
"visualstudiotoolsforunity.vstuc"
]
}
-10
View File
@@ -1,10 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Unity",
"type": "vstuc",
"request": "attach"
}
]
}
-71
View File
@@ -1,71 +0,0 @@
{
"files.exclude": {
"**/.DS_Store": true,
"**/.git": true,
"**/.vs": true,
"**/.gitmodules": true,
"**/.vsconfig": true,
"**/*.booproj": true,
"**/*.pidb": true,
"**/*.suo": true,
"**/*.user": true,
"**/*.userprefs": true,
"**/*.unityproj": true,
"**/*.dll": true,
"**/*.exe": true,
"**/*.pdf": true,
"**/*.mid": true,
"**/*.midi": true,
"**/*.wav": true,
"**/*.gif": true,
"**/*.ico": true,
"**/*.jpg": true,
"**/*.jpeg": true,
"**/*.png": true,
"**/*.psd": true,
"**/*.tga": true,
"**/*.tif": true,
"**/*.tiff": true,
"**/*.3ds": true,
"**/*.3DS": true,
"**/*.fbx": true,
"**/*.FBX": true,
"**/*.lxo": true,
"**/*.LXO": true,
"**/*.ma": true,
"**/*.MA": true,
"**/*.obj": true,
"**/*.OBJ": true,
"**/*.asset": true,
"**/*.cubemap": true,
"**/*.flare": true,
"**/*.mat": true,
"**/*.meta": true,
"**/*.prefab": true,
"**/*.unity": true,
"build/": true,
"Build/": true,
"Library/": true,
"library/": true,
"obj/": true,
"Obj/": true,
"Logs/": true,
"logs/": true,
"ProjectSettings/": true,
"UserSettings/": true,
"temp/": true,
"Temp/": true
},
"files.associations": {
"*.asset": "yaml",
"*.meta": "yaml",
"*.prefab": "yaml",
"*.unity": "yaml",
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.sln": "*.csproj",
"*.slnx": "*.csproj"
},
"dotnet.defaultSolution": "VRBeatSaber.slnx"
}
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f5bb785ff13dd464d973cb80c6221e88
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-956
View File
@@ -1,956 +0,0 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>
/// Tools → VRBeatSaber → 전체 자동 설정
/// 씬 생성, 프리팹 생성, Build Settings 등록까지 한 번에 처리
/// </summary>
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()
{
if (!EditorUtility.DisplayDialog(
"VRBeatSaber 자동 설정",
"Intro / SongSelect / SongCreator 씬과\nSongCard 프리팹, Build Settings를 자동으로 설정합니다.\n\n현재 씬이 저장되지 않으면 경고가 뜹니다.",
"실행", "취소")) return;
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
EnsureFolder("Assets/Scenes");
EnsureFolder("Assets/Prefab");
// 1. SongCard 프리팹
s_cardPrefab = CreateSongCardPrefab();
// 2. SongSelect 씬 생성
BuildSongSelectScene();
// 3. SongCreator 씬 생성
BuildSongCreatorScene();
// 4. Intro 씬에 UI 추가
ModifyIntroScene();
// 5. Game 씬 AudioClip 제거
FixGameScene();
// 6. Build Settings 등록
RegisterBuildSettings();
AssetDatabase.Refresh();
EditorUtility.DisplayDialog(
"완료",
"자동 설정 완료!\n\n" +
"[ 직접 해야 할 것 (2가지) ]\n\n" +
"1. SongCreator 씬 → [CreatorManager]\n" +
" → NasPublisher → Nas Password\n" +
" (DSM 비밀번호 입력)\n\n" +
"2. Quest에 MP3 파일 넣기\n" +
" adb push 파일.mp3\n" +
" /sdcard/Android/data/{패키지명}/files/input/",
"확인");
}
// ══════════════════════════════════════════════════════════
// 1. SongCard 프리팹
// ══════════════════════════════════════════════════════════
private static GameObject CreateSongCardPrefab()
{
var root = new GameObject("SongCard");
root.AddComponent<Image>().color = new Color(0.12f, 0.12f, 0.12f);
var btn = root.AddComponent<Button>();
var rt = root.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(430, 90);
// 다운로드 뱃지 (우측 녹색 원)
var badge = new GameObject("DownloadedBadge");
badge.transform.SetParent(root.transform, false);
badge.AddComponent<Image>().color = new Color(0.2f, 0.9f, 0.4f);
var bRt = badge.GetComponent<RectTransform>();
bRt.anchorMin = new Vector2(1, 0.5f);
bRt.anchorMax = new Vector2(1, 0.5f);
bRt.pivot = new Vector2(1, 0.5f);
bRt.anchoredPosition = new Vector2(-12, 0);
bRt.sizeDelta = new Vector2(20, 20);
// 제목
var titleTMP = MakeTMP("TitleText", root.transform, "곡 제목",
new Vector2(-10, 16), new Vector2(370, 30), 20);
// 아티스트
var artistTMP = MakeTMP("ArtistText", root.transform, "아티스트",
new Vector2(-30, -16), new Vector2(260, 24), 15);
artistTMP.color = new Color(0.7f, 0.7f, 0.7f);
// 길이
var durTMP = MakeTMP("DurationText", root.transform, "0:00",
new Vector2(-45, -16), new Vector2(120, 24), 15);
durTMP.color = new Color(0.6f, 0.6f, 0.6f);
durTMP.alignment = TextAlignmentOptions.MidlineRight;
// SongCard 컴포넌트 바인딩
var card = root.AddComponent<SongCard>();
var so = new SerializedObject(card);
Bind(so, "titleText", titleTMP);
Bind(so, "artistText", artistTMP);
Bind(so, "durationText", durTMP);
Bind(so, "downloadedBadge", badge);
Bind(so, "button", btn);
so.ApplyModifiedProperties();
var prefab = PrefabUtility.SaveAsPrefabAsset(root, "Assets/Prefab/SongCard.prefab");
Object.DestroyImmediate(root);
Debug.Log("[SceneBuilder] SongCard 프리팹 생성 완료");
return prefab;
}
// ══════════════════════════════════════════════════════════
// 2. SongSelect 씬
// ══════════════════════════════════════════════════════════
private static void BuildSongSelectScene()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
MakeCamera(new Vector3(0, 1.6f, 0));
MakeEventSystem();
// SongLibrary는 DontDestroyOnLoad를 사용하므로 단독 오브젝트에 배치
// (GameManager와 같이 두면 DownloadManager까지 DontDestroyOnLoad로 이동해 버림)
var slGO = new GameObject("[SongLibrary]");
slGO.AddComponent<SongLibrary>();
// GameManager: 씬마다 새로 생성되는 매니저들
var gm = new GameObject("[GameManager]");
gm.AddComponent<CacheManager>();
var dlMgr = gm.AddComponent<DownloadManager>();
var canvas = MakeCanvas(new Vector2(1200, 700), new Vector3(0, 1.5f, 2f));
// 탭
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));
// 목록 (좌측)
var listPanel = MakePanel("ListPanel", canvas.transform, new Vector2(-355, -30), new Vector2(460, 600));
var (_, cardContainer) = MakeScrollRect("SongScroll", listPanel.transform, Vector2.zero, new Vector2(450, 590));
// 상세 패널 (우측)
var detailGO = MakePanel("DetailPanel", canvas.transform, new Vector2(250, -30), new Vector2(680, 600));
var detail = detailGO.AddComponent<SongDetailPanel>();
var titleTxt = MakeTMP("TitleText", detailGO.transform, "곡 제목", new Vector2(0, 250), new Vector2(640, 50), 28);
var artistTxt = MakeTMP("ArtistText", detailGO.transform, "아티스트", new Vector2(0, 200), new Vector2(640, 36), 20);
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 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 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 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);
MakeTMP("LoadingText", loadingOvr.transform, "불러오는 중...", Vector2.zero, new Vector2(400, 60), 26);
var errorOvr = MakePanel("ErrorOverlay", canvas.transform, Vector2.zero, new Vector2(1200, 700));
errorOvr.GetComponent<Image>().color = new Color(0, 0, 0, 0.75f);
var errorTxt = MakeTMP("ErrorText", errorOvr.transform, "", Vector2.zero, new Vector2(700, 120), 22);
loadingOvr.SetActive(false);
errorOvr.SetActive(false);
detailGO.SetActive(false);
// SongDetailPanel 바인딩
var dso = new SerializedObject(detail);
Bind(dso, "titleText", titleTxt);
Bind(dso, "artistText", artistTxt);
Bind(dso, "infoText", infoTxt);
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);
dso.ApplyModifiedProperties();
// 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);
Bind(mso, "songCardPrefab", s_cardPrefab);
Bind(mso, "detailPanel", detail);
Bind(mso, "downloadManager", dlMgr);
Bind(mso, "loadingOverlay", loadingOvr);
Bind(mso, "errorOverlay", errorOvr);
Bind(mso, "errorText", errorTxt);
mso.ApplyModifiedProperties();
EditorSceneManager.SaveScene(scene, "Assets/Scenes/SongSelect.unity");
Debug.Log("[SceneBuilder] SongSelect 씬 완료");
}
// ══════════════════════════════════════════════════════════
// 3. SongCreator 씬
// ══════════════════════════════════════════════════════════
private static void BuildSongCreatorScene()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
MakeCamera(new Vector3(0, 1.6f, 0));
MakeEventSystem();
// SongLibrary 싱글턴 — SongCreator에서 직접 쓰진 않지만 DontDestroyOnLoad 체계 유지
new GameObject("[SongLibrary]").AddComponent<SongLibrary>();
var gm = new GameObject("[CreatorManager]");
var uploader = gm.AddComponent<BeatSageUploader>();
var publisher = gm.AddComponent<NasPublisher>();
var scMgr = gm.AddComponent<SongCreatorManager>();
var canvas = MakeCanvas(new Vector2(900, 800), new Vector3(0, 1.5f, 2f));
MakeTMP("TitleLabel", canvas.transform, "노래 만들기", new Vector2(0, 365), new Vector2(860, 55), 32);
// 음원 선택 (드롭다운 + 새로고침)
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);
// 음원 추가 (파일 선택 / 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 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 backBtnSC = MakeButton("BackButton", canvas.transform, "< 뒤로", new Vector2(346, 365), new Vector2(130, 50));
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, "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);
Bind(sso, "statusText", statusTxt);
Bind(sso, "progressSlider", progSlider);
Bind(sso, "beatSageUploader", uploader);
Bind(sso, "nasPublisher", publisher);
sso.ApplyModifiedProperties();
EditorSceneManager.SaveScene(scene, "Assets/Scenes/SongCreator.unity");
Debug.Log("[SceneBuilder] SongCreator 씬 완료");
}
// ══════════════════════════════════════════════════════════
// 4. Intro 씬 수정
// ══════════════════════════════════════════════════════════
private static void ModifyIntroScene()
{
const string path = "Assets/Scenes/Intro.unity";
if (!File.Exists(path)) { Debug.LogWarning("[SceneBuilder] Intro.unity 없음 — 건너뜀"); return; }
var scene = EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
// 이미 IntroManager가 있으면 건너뜀
if (Object.FindObjectOfType<IntroManager>() != null)
{
Debug.Log("[SceneBuilder] Intro 씬 IntroManager 이미 존재 — 건너뜀");
return;
}
var canvas = MakeCanvas(new Vector2(600, 420), new Vector3(0, 1.5f, 2f));
MakeTMP("TitleText", canvas.transform, "VR BEAT SABER", new Vector2(0, 145), new Vector2(560, 70), 38);
var playBtn = MakeButton("PlayButton", canvas.transform, "게임하기", new Vector2(0, 30), new Vector2(420, 85));
var createBtn = MakeButton("CreateButton", canvas.transform, "노래만들기", new Vector2(0, -80), new Vector2(420, 85));
var introGO = new GameObject("[IntroManager]");
var mgr = introGO.AddComponent<IntroManager>();
var iso = new SerializedObject(mgr);
Bind(iso, "playButton", playBtn.GetComponent<Button>());
Bind(iso, "createButton", createBtn.GetComponent<Button>());
iso.ApplyModifiedProperties();
EditorSceneManager.SaveScene(scene);
Debug.Log("[SceneBuilder] Intro 씬 수정 완료");
}
// ══════════════════════════════════════════════════════════
// 5. Game 씬 — AudioClip 참조 제거
// ══════════════════════════════════════════════════════════
private static void FixGameScene()
{
const string path = "Assets/Scenes/Game.unity";
if (!File.Exists(path)) { Debug.LogWarning("[SceneBuilder] Game.unity 없음 — 건너뜀"); return; }
var scene = EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
var spawner = Object.FindObjectOfType<Spawner>();
if (spawner == null) { Debug.LogWarning("[SceneBuilder] Spawner 없음"); return; }
// Spawner가 참조하는 AudioSource의 AudioClip 제거 (런타임에 로컬 파일에서 로드)
var sso = new SerializedObject(spawner);
var asProp = sso.FindProperty("audioSource");
if (asProp?.objectReferenceValue is AudioSource audioSrc)
{
var asObj = new SerializedObject(audioSrc);
var clipProp = asObj.FindProperty("m_audioClip");
if (clipProp != null)
{
clipProp.objectReferenceValue = null;
asObj.ApplyModifiedProperties();
Debug.Log("[SceneBuilder] AudioClip 참조 제거 완료");
}
}
EditorSceneManager.SaveScene(scene);
Debug.Log("[SceneBuilder] Game 씬 정리 완료");
}
// ══════════════════════════════════════════════════════════
// 6. Build Settings
// ══════════════════════════════════════════════════════════
private static void RegisterBuildSettings()
{
var paths = new[]
{
"Assets/Scenes/Intro.unity",
"Assets/Scenes/SongSelect.unity",
"Assets/Scenes/SongCreator.unity",
"Assets/Scenes/Game.unity",
"Assets/Scenes/MapEditorScene.unity",
};
var list = new List<EditorBuildSettingsScene>();
foreach (var p in paths)
{
if (File.Exists(p))
list.Add(new EditorBuildSettingsScene(p, true));
else
Debug.LogWarning($"[SceneBuilder] 씬 파일 없음: {p}");
}
EditorBuildSettings.scenes = list.ToArray();
Debug.Log($"[SceneBuilder] Build Settings 등록 완료: {list.Count}개");
}
// ══════════════════════════════════════════════════════════
// UI 헬퍼
// ══════════════════════════════════════════════════════════
private static void MakeCamera(Vector3 pos)
{
var go = new GameObject("Main Camera");
go.AddComponent<Camera>();
go.AddComponent<AudioListener>();
go.tag = "MainCamera";
go.transform.position = pos;
}
private static void MakeEventSystem()
{
var go = new GameObject("EventSystem");
go.AddComponent<EventSystem>();
// New Input System 사용 시 StandaloneInputModule 대신 InputSystemUIInputModule 필요
// 없으면 StandaloneInputModule로 폴백 (Player Settings에서 Both로 설정 시 동작)
var inputModuleType = System.Type.GetType(
"UnityEngine.InputSystem.UI.InputSystemUIInputModule, Unity.InputSystem");
if (inputModuleType != null)
go.AddComponent(inputModuleType);
else
go.AddComponent<StandaloneInputModule>();
}
private static GameObject MakeCanvas(Vector2 size, Vector3 worldPos)
{
var go = new GameObject("Canvas");
var c = go.AddComponent<Canvas>();
c.renderMode = RenderMode.WorldSpace;
go.AddComponent<CanvasScaler>();
// VR(XR Interaction Toolkit)에서는 TrackedDeviceGraphicRaycaster 필요
var trackedRaycasterType = System.Type.GetType(
"UnityEngine.XR.Interaction.Toolkit.UI.TrackedDeviceGraphicRaycaster, Unity.XR.Interaction.Toolkit");
if (trackedRaycasterType != null)
go.AddComponent(trackedRaycasterType);
else
go.AddComponent<GraphicRaycaster>();
var rt = go.GetComponent<RectTransform>();
rt.sizeDelta = size;
go.transform.position = worldPos;
go.transform.localScale = Vector3.one * 0.002f;
return go;
}
private static GameObject MakePanel(string name, Transform parent, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
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;
return go;
}
private static GameObject MakeButton(string name, Transform parent, string label, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
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;
rt.sizeDelta = size;
var tgo = new GameObject("Text");
tgo.transform.SetParent(go.transform, false);
var tmp = tgo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 18;
tmp.alignment = TextAlignmentOptions.Center;
FullStretch(tgo.GetComponent<RectTransform>());
return go;
}
private static TMP_Text MakeTMP(string name, Transform parent, string text, Vector2 pos, Vector2 size, int fs)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fs;
tmp.alignment = TextAlignmentOptions.Center;
var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos;
rt.sizeDelta = size;
return tmp;
}
private static TMP_InputField MakeInputField(string name, Transform parent, string ph, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
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;
rt.sizeDelta = size;
var area = new GameObject("Text Area");
area.transform.SetParent(go.transform, false);
var aRt = area.AddComponent<RectTransform>();
aRt.anchorMin = Vector2.zero; aRt.anchorMax = Vector2.one;
aRt.sizeDelta = new Vector2(-16, -8);
var phGO = new GameObject("Placeholder");
phGO.transform.SetParent(area.transform, false);
var phTMP = phGO.AddComponent<TextMeshProUGUI>();
phTMP.text = ph; phTMP.color = new Color(0.5f, 0.5f, 0.5f); phTMP.fontSize = 16;
FullStretch(phGO.GetComponent<RectTransform>());
var inGO = new GameObject("Text");
inGO.transform.SetParent(area.transform, false);
var inTMP = inGO.AddComponent<TextMeshProUGUI>();
inTMP.fontSize = 16;
FullStretch(inGO.GetComponent<RectTransform>());
field.textViewport = aRt;
field.placeholder = phTMP;
field.textComponent = inTMP;
return field;
}
private static TMP_Dropdown MakeDropdown(string name, Transform parent, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
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;
rt.sizeDelta = size;
// Caption label (선택된 항목 표시)
var labelGO = new GameObject("Label");
labelGO.transform.SetParent(go.transform, false);
var labelTMP = labelGO.AddComponent<TextMeshProUGUI>();
labelTMP.text = "";
labelTMP.fontSize = 16;
var labelRt = labelGO.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero; labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = new Vector2(10, 2); labelRt.offsetMax = new Vector2(-28, -2);
// Arrow indicator
var arrowGO = new GameObject("Arrow");
arrowGO.transform.SetParent(go.transform, false);
arrowGO.AddComponent<Image>().color = Color.white;
var arrowRt = arrowGO.GetComponent<RectTransform>();
arrowRt.anchorMin = new Vector2(1, 0.5f);
arrowRt.anchorMax = new Vector2(1, 0.5f);
arrowRt.pivot = new Vector2(1, 0.5f);
arrowRt.anchoredPosition = new Vector2(-6, 0);
arrowRt.sizeDelta = new Vector2(20, 20);
// Template (드롭다운 목록, 비활성 상태로 시작)
var tplGO = new GameObject("Template");
tplGO.transform.SetParent(go.transform, false);
tplGO.AddComponent<Image>().color = new Color(0.12f, 0.12f, 0.12f);
var tplSr = tplGO.AddComponent<ScrollRect>();
var tplRt = tplGO.GetComponent<RectTransform>();
tplRt.anchorMin = new Vector2(0, 0); tplRt.anchorMax = new Vector2(1, 0);
tplRt.pivot = new Vector2(0.5f, 1);
tplRt.anchoredPosition = new Vector2(0, 2);
tplRt.sizeDelta = new Vector2(0, 150);
var vpGO = new GameObject("Viewport");
vpGO.transform.SetParent(tplGO.transform, false);
vpGO.AddComponent<Image>();
vpGO.AddComponent<Mask>().showMaskGraphic = false;
var vpRt = vpGO.GetComponent<RectTransform>();
FullStretch(vpRt);
var contentGO = new GameObject("Content");
contentGO.transform.SetParent(vpGO.transform, false);
var contentRt = contentGO.AddComponent<RectTransform>();
contentRt.anchorMin = new Vector2(0, 1); contentRt.anchorMax = Vector2.one;
contentRt.pivot = new Vector2(0.5f, 1);
contentRt.anchoredPosition = Vector2.zero; contentRt.sizeDelta = Vector2.zero;
tplSr.viewport = vpRt;
tplSr.content = contentRt;
tplSr.horizontal = false;
// Item 템플릿 (Content 안, 드롭다운이 복제해서 사용)
var itemGO = new GameObject("Item");
itemGO.transform.SetParent(contentGO.transform, false);
var itemTgl = itemGO.AddComponent<Toggle>();
var itemRt = itemGO.GetComponent<RectTransform>();
itemRt.anchorMin = new Vector2(0, 0.5f); itemRt.anchorMax = new Vector2(1, 0.5f);
itemRt.sizeDelta = new Vector2(0, 30);
var iBgGO = new GameObject("Item Background");
iBgGO.transform.SetParent(itemGO.transform, false);
var iBgImg = iBgGO.AddComponent<Image>();
iBgImg.color = new Color(0.15f, 0.15f, 0.15f);
FullStretch(iBgGO.GetComponent<RectTransform>());
var iCkGO = new GameObject("Item Checkmark");
iCkGO.transform.SetParent(itemGO.transform, false);
var iCkImg = iCkGO.AddComponent<Image>();
iCkImg.color = new Color(0.2f, 0.9f, 0.4f);
var iCkRt = iCkGO.GetComponent<RectTransform>();
iCkRt.anchorMin = new Vector2(0, 0.5f); iCkRt.anchorMax = new Vector2(0, 0.5f);
iCkRt.sizeDelta = new Vector2(18, 18); iCkRt.anchoredPosition = new Vector2(12, 0);
var iLblGO = new GameObject("Item Label");
iLblGO.transform.SetParent(itemGO.transform, false);
var iLblTMP = iLblGO.AddComponent<TextMeshProUGUI>();
iLblTMP.text = "Option"; iLblTMP.fontSize = 14;
var iLblRt = iLblGO.GetComponent<RectTransform>();
iLblRt.anchorMin = Vector2.zero; iLblRt.anchorMax = Vector2.one;
iLblRt.offsetMin = new Vector2(28, 1); iLblRt.offsetMax = new Vector2(-8, -2);
itemTgl.targetGraphic = iBgImg;
itemTgl.graphic = iCkImg;
dd.template = tplRt;
dd.captionText = labelTMP;
dd.itemText = iLblTMP;
tplGO.SetActive(false);
return dd;
}
private static Toggle MakeToggle(string name, Transform parent, string label, Vector2 pos)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var toggle = go.AddComponent<Toggle>();
var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos;
rt.sizeDelta = new Vector2(140, 40);
var bg = new GameObject("Background");
bg.transform.SetParent(go.transform, false);
bg.AddComponent<Image>().color = new Color(0.25f, 0.25f, 0.25f);
var bRt = bg.GetComponent<RectTransform>();
bRt.anchoredPosition = new Vector2(-45, 0);
bRt.sizeDelta = new Vector2(28, 28);
var ck = new GameObject("Checkmark");
ck.transform.SetParent(bg.transform, false);
var ckImg = ck.AddComponent<Image>();
ckImg.color = new Color(0.2f, 0.9f, 0.4f);
FullStretch(ck.GetComponent<RectTransform>());
var lGO = new GameObject("Label");
lGO.transform.SetParent(go.transform, false);
var lTMP = lGO.AddComponent<TextMeshProUGUI>();
lTMP.text = label; lTMP.fontSize = 16;
var lRt = lGO.GetComponent<RectTransform>();
lRt.anchoredPosition = new Vector2(20, 0);
lRt.sizeDelta = new Vector2(95, 30);
toggle.targetGraphic = bg.GetComponent<Image>();
toggle.graphic = ckImg;
toggle.isOn = false;
return toggle;
}
private static Slider MakeSlider(string name, Transform parent, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var slider = go.AddComponent<Slider>();
var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos;
rt.sizeDelta = size;
var bg = new GameObject("Background");
bg.transform.SetParent(go.transform, false);
bg.AddComponent<Image>().color = new Color(0.25f, 0.25f, 0.25f);
var bRt = bg.GetComponent<RectTransform>();
bRt.anchorMin = new Vector2(0, 0.25f); bRt.anchorMax = new Vector2(1, 0.75f);
bRt.sizeDelta = Vector2.zero;
var fa = new GameObject("Fill Area");
fa.transform.SetParent(go.transform, false);
var faRt = fa.AddComponent<RectTransform>();
faRt.anchorMin = new Vector2(0, 0.25f); faRt.anchorMax = new Vector2(1, 0.75f);
faRt.sizeDelta = new Vector2(-20, 0);
var fill = new GameObject("Fill");
fill.transform.SetParent(fa.transform, false);
fill.AddComponent<Image>().color = new Color(0.2f, 0.7f, 1f);
var fRt = fill.GetComponent<RectTransform>();
fRt.anchorMin = Vector2.zero; fRt.anchorMax = new Vector2(0, 1);
fRt.sizeDelta = new Vector2(10, 0);
slider.fillRect = fRt;
slider.value = 0f;
return slider;
}
private static (ScrollRect, Transform) MakeScrollRect(string name, Transform parent, Vector2 pos, Vector2 size)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
go.AddComponent<Image>().color = new Color(0, 0, 0, 0.2f);
var sr = go.AddComponent<ScrollRect>();
var rt = go.GetComponent<RectTransform>();
rt.anchoredPosition = pos; rt.sizeDelta = size;
var vp = new GameObject("Viewport");
vp.transform.SetParent(go.transform, false);
vp.AddComponent<Image>();
vp.AddComponent<Mask>().showMaskGraphic = false;
FullStretch(vp.GetComponent<RectTransform>());
var content = new GameObject("CardContainer");
content.transform.SetParent(vp.transform, false);
var vlg = content.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 8; vlg.childForceExpandWidth = true; vlg.childForceExpandHeight = false;
vlg.padding = new RectOffset(8, 8, 8, 8);
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var cRt = content.GetComponent<RectTransform>();
cRt.anchorMin = new Vector2(0, 1); cRt.anchorMax = Vector2.one;
cRt.pivot = new Vector2(0.5f, 1); cRt.sizeDelta = Vector2.zero;
sr.viewport = vp.GetComponent<RectTransform>();
sr.content = cRt;
sr.horizontal = false;
return (sr, content.transform);
}
private static void FullStretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.anchoredPosition = Vector2.zero; rt.sizeDelta = Vector2.zero;
}
private static void Bind(SerializedObject so, string field, Object value)
{
var prop = so.FindProperty(field);
if (prop != null) prop.objectReferenceValue = value;
else Debug.LogWarning($"[SceneBuilder] 필드 없음: {field}");
}
private static void EnsureFolder(string path)
{
if (!Directory.Exists(path))
{
var parts = path.Split('/');
AssetDatabase.CreateFolder(string.Join("/", parts[..^1]), parts[^1]);
}
}
}
#endif
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8041d843681d86e41a11512f66dd9933
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: d9439e6b8deca6b46bf3953bad7b1175
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because one or more lines are too long
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 3c73f7e613a4dd743a3158bde2595054
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
fileFormatVersion: 2
guid: 3ad25416cafb5d042b5ba06d88e556b7
TrueTypeFontImporter:
externalObjects: {}
serializedVersion: 4
fontSize: 16
forceTextureCase: -2
characterSpacing: 0
characterPadding: 1
includeFontData: 1
fontNames:
- NanumGothic
fallbackFontReferences: []
customCharacters:
fontRenderingMode: 0
ascentCalculationMode: 1
useLegacyBoundsCalculation: 0
shouldRoundAdvanceValue: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
fileFormatVersion: 2
guid: 002e641185078164fb6b5ef4149b8a8b
TrueTypeFontImporter:
externalObjects: {}
serializedVersion: 4
fontSize: 16
forceTextureCase: -2
characterSpacing: 0
characterPadding: 1
includeFontData: 1
fontNames:
- Malgun Gothic
fallbackFontReferences: []
customCharacters:
fontRenderingMode: 0
ascentCalculationMode: 1
useLegacyBoundsCalculation: 0
shouldRoundAdvanceValue: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -1,23 +0,0 @@
fileFormatVersion: 2
guid: e3e288728e4a1234ca4d7d2b6f7b59c7
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 7801801390c7672459c73486fd2a4470
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9e2d9ef055a8f384091d254c908bc783
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 49c5ba60a1a135c499a98e1f51c08a63
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-57
View File
@@ -1,57 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class BeatSageRoot
{
public string _version;
public List<BeatSageNote> _notes;
}
[Serializable]
public class BeatSageNote
{
public float _time;
public int _lineIndex;
public int _lineLayer;
public int _type;
public int _cutDirection;
}
public static class BeatSageConverter
{
public static List<NoteData> Convert(string rawJson, float bpm)
{
var result = new List<NoteData>();
BeatSageRoot sageData = JsonUtility.FromJson<BeatSageRoot>(rawJson);
if (sageData?._notes == null)
{
Debug.LogWarning("[BeatSageConverter] 파싱 실패 또는 노트 없음.");
return result;
}
foreach (var note in sageData._notes)
{
// 일반 노트(0: 빨강, 1: 파랑)만 처리, 폭탄(3) 등 제외
if (note._type != 0 && note._type != 1) continue;
result.Add(new NoteData
{
time = (note._time * 60f) / bpm,
position = note._lineIndex,
colorType = note._type
});
}
Debug.Log($"[BeatSageConverter] 변환 완료: {result.Count}개 노트");
return result;
}
public static string ToMapJson(List<NoteData> notes)
{
var mapData = new MapData { target = notes };
return JsonUtility.ToJson(mapData, true);
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 17a8872686c9b054f9bbdac194e41fb6
-266
View File
@@ -1,266 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
// Beat Sage API 연동 (beatsage.com 역분석 기반)
// 출처: BadgerHobbs/BeatSage-Downloader 소스코드
public class BeatSageUploader : MonoBehaviour
{
private const string BASE_URL = "https://beatsage.com";
private const string CREATE_EP = "/beatsaber_custom_level_create";
private const string HEARTBEAT_EP = "/beatsaber_custom_level_heartbeat/{0}"; // {0} = levelId
private const string DOWNLOAD_EP = "/beatsaber_custom_level_download/{0}"; // {0} = levelId
private const float POLL_INTERVAL = 5f;
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()
{
{ "normal", "Normal" },
{ "hard", "Hard" },
{ "expert", "Expert" },
{ "expertplus", "ExpertPlus" },
};
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
private static readonly Dictionary<string, string> DatFileNames = new()
{
{ "normal", "Normal.dat" },
{ "hard", "Hard.dat" },
{ "expert", "Expert.dat" },
{ "expertplus", "ExpertPlus.dat" },
};
public string CurrentStatus { get; private set; } = "";
// ── Public API ───────────────────────────────────────────
public IEnumerator Upload(
string audioPath,
List<string> difficulties,
float bpm,
Action<float> onProgress,
Action<Dictionary<string, List<NoteData>>> onComplete,
Action<string> onError)
{
// 1단계: 레벨 생성 요청
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("[2/4] AI 맵 생성 시작...");
bool ready = false;
float elapsed = 0f;
while (!ready && elapsed < POLL_TIMEOUT)
{
yield return new WaitForSeconds(POLL_INTERVAL);
elapsed += POLL_INTERVAL;
bool error = false;
yield return PollHeartbeat(levelId,
status =>
{
ready = string.Equals(status, "generated", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, "done", System.StringComparison.OrdinalIgnoreCase);
error = string.Equals(status, "error", System.StringComparison.OrdinalIgnoreCase);
},
onError);
if (error) { onError?.Invoke("Beat Sage 생성 실패 (error 상태)"); yield break; }
float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT);
onProgress?.Invoke(0.15f + progress * 0.6f);
SetStatus($"[2/4] AI 맵 생성 중... {(int)elapsed}초 경과");
}
if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; }
// 3단계: .zip 다운로드
SetStatus("[3/4] 결과 다운로드 중...");
byte[] zipBytes = null;
yield return DownloadZip(levelId,
bytes => zipBytes = bytes,
onError);
if (zipBytes == null) yield break;
onProgress?.Invoke(0.9f);
// 4단계: .zip 해제 + BeatSageConverter 변환
SetStatus("[3/4] 맵 데이터 변환 중...");
Dictionary<string, List<NoteData>> maps = null;
try
{
maps = ExtractAndConvert(zipBytes, difficulties, bpm);
}
catch (Exception e)
{
onError?.Invoke($"변환 실패: {e.Message}");
yield break;
}
onProgress?.Invoke(1f);
SetStatus("[3/4] 변환 완료");
onComplete?.Invoke(maps);
}
// ── Beat Sage API 요청 ────────────────────────────────────
private IEnumerator CreateLevel(string audioPath, List<string> difficulties,
Action<string> onSuccess, Action<string> onError)
{
byte[] audioBytes = File.ReadAllBytes(audioPath);
string fileName = Path.GetFileName(audioPath);
// 난이도: 알 수 없는 값(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>
{
new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"),
new MultipartFormDataSection("audio_metadata_title", " "),
new MultipartFormDataSection("audio_metadata_artist", " "),
new MultipartFormDataSection("difficulties", diffStr),
new MultipartFormDataSection("modes", "Standard"),
new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"),
new MultipartFormDataSection("environment", "DefaultEnvironment"),
new MultipartFormDataSection("system_tag", "v2"),
};
using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form);
req.SetRequestHeader("Accept", "*/*");
req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0");
yield return req.SendWebRequest();
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;
}
string json = req.downloadHandler.text;
string levelId = ParseJsonString(json, "id");
Debug.Log($"[BeatSageUploader] 생성 응답: {json}");
if (string.IsNullOrEmpty(levelId))
{
onError?.Invoke($"levelId 파싱 실패. 응답: {json}");
yield break;
}
onSuccess?.Invoke(levelId);
}
private IEnumerator PollHeartbeat(string levelId, Action<string> onStatus, Action<string> onError)
{
string url = BASE_URL + string.Format(HEARTBEAT_EP, levelId);
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"상태 확인 실패: {req.error}");
yield break;
}
// 응답 예: { "status": "generated" } | { "status": "pending" } | { "status": "error" }
string status = ParseJsonString(req.downloadHandler.text, "status");
Debug.Log($"[BeatSageUploader] 상태: {status}");
onStatus?.Invoke(status ?? "");
}
private IEnumerator DownloadZip(string levelId, Action<byte[]> onSuccess, Action<string> onError)
{
string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId);
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"다운로드 실패: {req.error}");
yield break;
}
onSuccess?.Invoke(req.downloadHandler.data);
}
// ── .zip 해제 + 변환 ──────────────────────────────────────
private static Dictionary<string, List<NoteData>> ExtractAndConvert(
byte[] zipBytes, List<string> difficulties, float bpm)
{
var result = new Dictionary<string, List<NoteData>>();
using var ms = new MemoryStream(zipBytes);
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
foreach (string diff in difficulties)
{
if (!DatFileNames.TryGetValue(diff, out string datName)) continue;
// 대소문자 무시 검색
ZipArchiveEntry entry = null;
foreach (var e in archive.Entries)
{
if (string.Equals(e.Name, datName, StringComparison.OrdinalIgnoreCase))
{ entry = e; break; }
}
if (entry == null)
{
Debug.LogWarning($"[BeatSageUploader] {datName} 없음 — 건너뜀");
continue;
}
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
result[diff] = BeatSageConverter.Convert(reader.ReadToEnd(), bpm);
}
return result;
}
// ── 유틸 ─────────────────────────────────────────────────
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
}
private void SetStatus(string msg) => CurrentStatus = msg;
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 36b8f9eab08910f4285303fdcee4715e
-106
View File
@@ -1,106 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
// 앱 시작 시 캐시 총 용량을 확인하고 1GB 초과 시 LRU 순으로 자동 삭제
public class CacheManager : MonoBehaviour
{
private const long MaxCacheBytes = 1L * 1024 * 1024 * 1024; // 1 GB
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Unity ────────────────────────────────────────────────
private void Start()
{
RunEviction();
}
// ── Public API ───────────────────────────────────────────
public long TotalCacheBytes()
{
if (!Directory.Exists(CacheRoot)) return 0;
return Directory.GetFiles(CacheRoot, "*", SearchOption.AllDirectories)
.Sum(f => new FileInfo(f).Length);
}
// 강제 정리 (UI에서 수동 호출용)
public void RunEviction()
{
if (!Directory.Exists(CacheRoot)) return;
long total = TotalCacheBytes();
if (total <= MaxCacheBytes)
{
Debug.Log($"[CacheManager] 용량 정상: {FormatBytes(total)}");
return;
}
// 곡 폴더를 마지막 접근 시간 오름차순 정렬 (가장 오래된 것부터)
List<SongDirInfo> dirs = GetSongDirs();
dirs.Sort((a, b) => a.lastAccessed.CompareTo(b.lastAccessed));
foreach (SongDirInfo dir in dirs)
{
if (total <= MaxCacheBytes) break;
total -= dir.sizeBytes;
Directory.Delete(dir.path, recursive: true);
SongLibrary.Instance?.MarkSongRemoved(dir.songId);
Debug.Log($"[CacheManager] LRU 삭제: {dir.songId} ({FormatBytes(dir.sizeBytes)})");
}
Debug.Log($"[CacheManager] 정리 완료 → 현재 {FormatBytes(total)}");
}
// ── 내부 구현 ─────────────────────────────────────────────
private List<SongDirInfo> GetSongDirs()
{
var result = new List<SongDirInfo>();
foreach (string dir in Directory.GetDirectories(CacheRoot))
{
long size = Directory.GetFiles(dir, "*", SearchOption.AllDirectories)
.Sum(f => new FileInfo(f).Length);
// 마지막 접근 시간은 SongLibrary 기록 우선, 없으면 파일시스템 참조
DateTime lastAccessed = DateTime.MaxValue;
string songId = Path.GetFileName(dir);
LibraryEntry entry = SongLibrary.Instance?.GetAll()
.Find(e => e.songId == songId);
if (entry != null && DateTime.TryParse(entry.lastAccessedAt, out DateTime t))
lastAccessed = t;
else
lastAccessed = Directory.GetLastAccessTimeUtc(dir);
result.Add(new SongDirInfo
{
songId = songId,
path = dir,
sizeBytes = size,
lastAccessed = lastAccessed
});
}
return result;
}
private static string FormatBytes(long bytes)
{
if (bytes >= 1024 * 1024 * 1024) return $"{bytes / (1024f * 1024 * 1024):F2} GB";
if (bytes >= 1024 * 1024) return $"{bytes / (1024f * 1024):F1} MB";
return $"{bytes / 1024f:F0} KB";
}
private struct SongDirInfo
{
public string songId;
public string path;
public long sizeBytes;
public DateTime lastAccessed;
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6fab983d0eede0b488541a9404258c2b
-16
View File
@@ -1,16 +0,0 @@
using UnityEngine;
public class Cube : MonoBehaviour
{
void Update()
{
// Spawner.cs의 noteSpeed(2.0)와 이 숫자가 반드시 같아야 타이밍이 맞습니다.
transform.position += Time.deltaTime * transform.forward * 2;
// 화면 밖으로 나간 노트 자동 삭제 (최적화)
if (transform.position.z < -5f)
{
Destroy(gameObject);
}
}
}
-161
View File
@@ -1,161 +0,0 @@
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
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1c063d20f87d41d40a6a01c6bd1a1736
-162
View File
@@ -1,162 +0,0 @@
using System;
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
public void FetchSongsList(Action<SongsList> onSuccess, Action<string> onError = null)
{
StartCoroutine(GetText($"{baseUrl}/songs.json", json =>
{
SongsList list = JsonUtility.FromJson<SongsList>(json);
if (list == null)
onError?.Invoke("songs.json 파싱 실패");
else
onSuccess?.Invoke(list);
}, onError));
}
// 오디오 + 선택 난이도 맵 다운로드
public void DownloadSong(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError = null)
{
StartCoroutine(DownloadSongCoroutine(song, difficulty, onProgress, onComplete, onError));
}
public void DeleteSong(string songId)
{
string dir = SongDir(songId);
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
}
public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId));
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{
string path = MapPath(song, difficulty);
return path != null && File.Exists(path);
}
// Spawner에서 재생 경로를 얻을 때 사용
public string AudioPath(string songId)
=> Path.Combine(SongDir(songId), $"{songId}.mp3");
public string MapPath(SongInfo song, string difficulty)
{
DifficultyInfo info = song.difficulties.Get(difficulty);
if (info == null || string.IsNullOrEmpty(info.mapFile)) return null;
string fileName = Path.GetFileName(info.mapFile);
if (string.IsNullOrEmpty(fileName)) return null;
return Path.Combine(SongDir(song.id), fileName);
}
// ── 내부 구현 ─────────────────────────────────────────────
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError)
{
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
// 1단계: 오디오 (70%)
string audioPath = Path.Combine(songDir, $"{song.id}.mp3");
if (!File.Exists(audioPath))
{
bool failed = false;
yield return DownloadFile(
$"{baseUrl}/{song.audioFile}", audioPath,
p => onProgress?.Invoke(p * 0.7f),
err => { onError?.Invoke(err); failed = true; });
if (failed) yield break;
}
// 2단계: 맵 파일 (30%)
DifficultyInfo diffInfo = song.difficulties.Get(difficulty);
if (diffInfo == null)
{
onError?.Invoke($"난이도 '{difficulty}' 없음");
yield break;
}
if (string.IsNullOrEmpty(diffInfo.mapFile))
{
onError?.Invoke($"'{difficulty}' 맵 파일 정보 없음 — Creator에서 곡을 다시 생성해주세요");
yield break;
}
string mapPath = MapPath(song, difficulty);
if (mapPath != null) mapPath = Path.GetFullPath(mapPath);
if (mapPath == null)
{
onError?.Invoke($"'{difficulty}' 맵 경로 계산 실패");
yield break;
}
if (!File.Exists(mapPath))
{
bool failed = false;
yield return DownloadFile(
$"{baseUrl}/{diffInfo.mapFile}", mapPath,
p => onProgress?.Invoke(0.7f + p * 0.3f),
err => { onError?.Invoke(err); failed = true; });
if (failed) yield break;
}
onProgress?.Invoke(1f);
onComplete?.Invoke();
Debug.Log($"[DownloadManager] 완료: {song.title} ({difficulty})");
}
private IEnumerator DownloadFile(string url, string savePath,
Action<float> onProgress, Action<string> onError)
{
using var req = UnityWebRequest.Get(url);
req.downloadHandler = new DownloadHandlerFile(savePath);
req.SendWebRequest();
while (!req.isDone)
{
onProgress?.Invoke(req.downloadProgress);
yield return null;
}
if (req.result != UnityWebRequest.Result.Success)
{
if (File.Exists(savePath)) File.Delete(savePath);
onError?.Invoke($"다운로드 실패: {url} — {req.error}");
}
}
private IEnumerator GetText(string url, Action<string> onSuccess, Action<string> onError)
{
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
onError?.Invoke($"요청 실패: {url} — {req.error}");
else
onSuccess?.Invoke(req.downloadHandler.text);
}
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0a5af08e7a62bef4fa5126eea7be83d9
-18
View File
@@ -1,18 +0,0 @@
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));
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 72739d64e582ca840a355a30eba85d75
-6
View File
@@ -1,6 +0,0 @@
// 곡 선택 UI → Game 씬으로 선택 정보를 전달하는 정적 컨테이너
public static class GameSession
{
public static SongInfo SelectedSong;
public static string SelectedDifficulty;
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9b7e0d47d523f40448846f42acdbb11a
-21
View File
@@ -1,21 +0,0 @@
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class IntroManager : MonoBehaviour
{
[Header("버튼")]
[SerializeField] private Button playButton;
[SerializeField] private Button createButton;
[Header("씬 이름")]
[SerializeField] private string songSelectScene = "SongSelect";
[SerializeField] private string songCreatorScene = "SongCreator";
private void Start()
{
playButton.onClick.AddListener(() => SceneManager.LoadScene(songSelectScene));
createButton.onClick.AddListener(() => SceneManager.LoadScene(songCreatorScene));
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: af4b90127ce36634998ca553050a39e0
-215
View File
@@ -1,215 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEngine.InputSystem;
public class MapEditor : MonoBehaviour
{
[Header("Components")]
public AudioSource audioSource;
public Slider timelineSlider;
public TMP_Text timeText;
[Header("Song Settings")]
[Tooltip("확장자와 Map_을 제외한 저장할 노래 제목을 적으세요 (예: Life, Virus)")]
public string songName = "Life";
[Header("Note Guide (Large Background Numbers)")]
public TMP_Text[] guideTexts; // 4, 5, 1, 2 순서로 배치된 큰 텍스트들
[Header("Timeline (On Slider)")]
public RectTransform[] timelineRows; // Slider 내부 NoteContainer 안의 4개 줄
[Header("Prefabs")]
public GameObject noteUIPrefab; // 텍스트가 없는 10x10 크기의 작은 사각형 프리팹
private List<NoteData> recordedNotes = new List<NoteData>();
private Dictionary<NoteData, GameObject> visualNoteMap = new Dictionary<NoteData, GameObject>();
void Start()
{
// 슬라이더 클릭 시 해당 시간대로 이동하는 리스너
timelineSlider.onValueChanged.AddListener(OnSliderValueChanged);
}
void Update()
{
var kb = Keyboard.current;
if (kb == null) return;
// 1. 재생/정지 제어 (Space)
if (kb.spaceKey.wasPressedThisFrame)
{
if (audioSource.isPlaying) audioSource.Pause();
else audioSource.Play();
}
// 2. 10초 되감기 (Left Arrow)
if (kb.leftArrowKey.wasPressedThisFrame)
{
audioSource.time = Mathf.Max(0, audioSource.time - 10f);
}
// 3. 시간 및 UI 업데이트
if (audioSource.isPlaying)
{
UpdateTimelineUI();
}
// 4. 입력 처리 (가이드 색상 변경 + 노트 기록)
HandleEditorInput(kb);
}
void UpdateTimelineUI()
{
float currentTime = audioSource.time;
float totalTime = audioSource.clip.length;
timelineSlider.value = currentTime / totalTime;
timeText.text = string.Format("{0:00}:{1:00} / {2:00}:{3:00}",
(int)currentTime / 60, (int)currentTime % 60,
(int)totalTime / 60, (int)totalTime % 60);
}
void HandleEditorInput(Keyboard kb)
{
// 1. 단일 노트 컬러 선택 (Q: 빨강, W: 파랑)
int soloColor = kb.qKey.isPressed ? 0 : (kb.wKey.isPressed ? 1 : -1);
// 가이드 비주얼 업데이트
UpdateGuideVisuals(kb, soloColor);
if (!audioSource.isPlaying) return;
// 2. E 키를 누르고 있을 때 (더블 노트 모드: 1,4 빨강 / 2,5 파랑)
if (kb.eKey.isPressed)
{
// E를 누른 상태에서 숫자패드를 누르는 순간 기록
if (kb.numpad4Key.wasPressedThisFrame) ProcessNote(0, 0); // 4번 위치 빨강
if (kb.numpad5Key.wasPressedThisFrame) ProcessNote(1, 1); // 5번 위치 파랑
if (kb.numpad1Key.wasPressedThisFrame) ProcessNote(2, 0); // 1번 위치 빨강
if (kb.numpad2Key.wasPressedThisFrame) ProcessNote(3, 1); // 2번 위치 파랑
}
// 3. E 키를 누르하지 않았을 때 (기존 Q/W 단일 노트 모드)
else if (soloColor != -1)
{
if (kb.numpad4Key.wasPressedThisFrame) ProcessNote(0, soloColor);
if (kb.numpad5Key.wasPressedThisFrame) ProcessNote(1, soloColor);
if (kb.numpad1Key.wasPressedThisFrame) ProcessNote(2, soloColor);
if (kb.numpad2Key.wasPressedThisFrame) ProcessNote(3, soloColor);
}
}
void UpdateGuideVisuals(Keyboard kb, int color)
{
// 기본 흰색으로 초기화
foreach (var txt in guideTexts) txt.color = Color.white;
if (color == -1) return;
Color activeColor = (color == 0) ? Color.red : Color.blue;
// 키패드 누르고 있을 때 가이드 텍스트 색 변경
if (kb.numpad4Key.isPressed) guideTexts[0].color = activeColor;
if (kb.numpad5Key.isPressed) guideTexts[1].color = activeColor;
if (kb.numpad1Key.isPressed) guideTexts[2].color = activeColor;
if (kb.numpad2Key.isPressed) guideTexts[3].color = activeColor;
}
void ProcessNote(int pos, int color)
{
float currentTime = audioSource.time;
// 수정 로직: 현재 시간 0.1초 내에 같은 줄에 이미 노트가 있다면 제거 (덮어쓰기 준비)
NoteData existingNote = recordedNotes.Find(n => n.position == pos && Mathf.Abs(n.time - currentTime) < 0.1f);
if (existingNote != null) RemoveNote(existingNote);
RecordNote(pos, color);
}
void RecordNote(int pos, int color)
{
NoteData newNote = new NoteData { time = audioSource.time, position = pos, colorType = color };
recordedNotes.Add(newNote);
// 1. 생성 및 부모 설정
GameObject obj = Instantiate(noteUIPrefab, timelineRows[pos]);
RectTransform rt = obj.GetComponent<RectTransform>();
// 2. 크기 및 앵커 강제 고정 (길어짐 방지)
rt.anchorMin = new Vector2(0, 0.5f);
rt.anchorMax = new Vector2(0, 0.5f);
rt.pivot = new Vector2(0, 0.5f);
rt.sizeDelta = new Vector2(1, 15);
rt.localScale = Vector3.one; // 스케일 1로 초기화
// 3. 색상 강제 적용 (색상 사라짐 방지)
Image noteImage = obj.GetComponent<Image>();
if (noteImage != null)
{
// 0이면 빨강, 1이면 파랑 적용
noteImage.color = (color == 0) ? Color.red : Color.blue;
}
// 4. 위치 계산
float rowWidth = timelineRows[pos].rect.width;
float xPos = (newNote.time / audioSource.clip.length) * rowWidth;
rt.anchoredPosition = new Vector2(xPos, 0);
// 5. 관리 리스트에 추가
visualNoteMap.Add(newNote, obj);
obj.GetComponent<Button>().onClick.AddListener(() => RemoveNote(newNote));
}
void RemoveNote(NoteData note)
{
if (visualNoteMap.ContainsKey(note))
{
Destroy(visualNoteMap[note]);
visualNoteMap.Remove(note);
recordedNotes.Remove(note);
}
}
public void OnSliderValueChanged(float value)
{
// 정지 상태에서 슬라이더 조작 시 음악 시간 이동
if (!audioSource.isPlaying)
{
audioSource.time = value * audioSource.clip.length;
}
}
public void SaveBeatMap()
{
// 오디오 소스나 클립이 없을 때 예외 처리
if (audioSource == null || audioSource.clip == null)
{
Debug.LogError("[맵에디터] AudioSource에 음악 파일(Clip)이 등록되어 있지 않습니다!");
return;
}
// 😎 인스펙터의 Song Name을 무시하고, 실제 오디오 클립의 이름(예: Bethoven_Virus__Piano_)을 가져옵니다.
string actualSongName = audioSource.clip.name;
string fileName = "Map_" + actualSongName + ".json";
string filePath = Path.Combine(Application.streamingAssetsPath, fileName).Replace("\\", "/");
// 데이터 직렬화 및 저장
string json = JsonUtility.ToJson(new Serialization<NoteData>(recordedNotes));
File.WriteAllText(filePath, json);
Debug.Log($"[맵에디터] 오디오 이름으로 저장 완료! 파일명: {fileName} | 경로: {filePath}");
#if UNITY_EDITOR
UnityEditor.AssetDatabase.Refresh();
#endif
}
}
[System.Serializable]
public class Serialization<T>
{
public List<T> target;
public Serialization(List<T> target) { this.target = target; }
}
-81
View File
@@ -1,81 +0,0 @@
using UnityEngine;
using System.Collections.Generic;
using System.IO;
public class MusicSpawner : MonoBehaviour
{
public AudioSource audioSource;
public GameObject[] cube; // 0: 빨간색 박스, 1: 파란색 박스
public Transform[] point; // 4개의 스폰 지점
[Header("노래 설정")]
[Tooltip("확장자와 Map_을 제외한 불러올 노래 제목을 적으세요 (예: Life, Virus)")]
public string songName = "Life";
[Header("타이밍 설정")]
public float noteSpeed = 2.0f; // Cube.cs의 속도
public float distanceToHit = 10.0f; // 스폰 지점부터 플레이어까지 거리
private List<NoteData> spawnNotes = new List<NoteData>();
private int nextSpawnIndex = 0;
private float travelTime;
void Start()
{
// 1. 도달 시간 계산 (거리 / 속도)
travelTime = distanceToHit / noteSpeed;
// 2. [수정] 입력한 songName을 바탕으로 동적 JSON 로드
string fileName = "Map_" + songName + ".json";
string filePath = Path.Combine(Application.streamingAssetsPath, fileName);
if (File.Exists(filePath))
{
string jsonString = File.ReadAllText(filePath);
// 데이터 구조체 역직렬화 (MapData 공용 클래스 활용)
MapData data = JsonUtility.FromJson<MapData>(jsonString);
spawnNotes = data.target;
// 시간순 정렬로 스폰 꼬임 방지
spawnNotes.Sort((a, b) => a.time.CompareTo(b.time));
Debug.Log($"{fileName} 파일 정상 가동! 노드 데이터 확보 완료.");
}
else
{
Debug.LogError($"맵 파일을 찾을 수 없습니다! 지정 경로 확인 요망: {filePath}");
}
if (audioSource != null && audioSource.clip != null) audioSource.Play();
}
void Update()
{
if (audioSource == null || !audioSource.isPlaying) return;
// 핵심: 현재 시간 + 날아가는 시간 >= 기록 시간일 때 스폰
if (nextSpawnIndex < spawnNotes.Count)
{
float currentTime = audioSource.time;
var currentNote = spawnNotes[nextSpawnIndex];
if (currentTime + travelTime >= currentNote.time)
{
SpawnCube(currentNote.position, currentNote.colorType);
nextSpawnIndex++;
}
}
}
void SpawnCube(int pos, int color)
{
// 유효성 검사 (인덱스 범위 확인)
if (pos >= 0 && pos < point.Length && color >= 0 && color < cube.Length)
{
GameObject obj = Instantiate(cube[color], point[pos].position, point[pos].rotation);
// 비트세이버 스타일 랜덤 회전 추가
obj.transform.Rotate(transform.forward, 90 * Random.Range(0, 4));
}
}
}
Binary file not shown.
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 37c3efbe079d24e468e87c1f35337cd9
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-255
View File
@@ -1,255 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
// Synology DSM File Station API를 통해 NAS에 파일 업로드 및 songs.json 갱신
public class NasPublisher : MonoBehaviour
{
[Header("NAS 접속 정보")]
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000";
[SerializeField] private string nasAccount = "admin";
[SerializeField] private string nasRootPath = "/web/beatsaber";
[Header("정적 서버 URL (songs.json 읽기용)")]
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";
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 ───────────────────────────────────────────
public IEnumerator Publish(
SongInfo song,
string audioPath,
Dictionary<string, List<NoteData>> maps,
Action<float> onProgress,
Action onComplete,
Action<string> onError)
{
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
// 1단계: DSM 로그인
yield return Login(OnErr);
if (string.IsNullOrEmpty(_sid)) yield break;
onProgress?.Invoke(0.1f);
// 2단계: 오디오 업로드
yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
if (failed) { yield return Logout(); yield break; }
onProgress?.Invoke(0.4f);
// 3단계: 각 난이도 맵 JSON 업로드
int total = maps.Count;
int done = 0;
foreach (var kv in maps)
{
string fileName = $"Map_{song.id}_{kv.Key}.json";
string json = BeatSageConverter.ToMapJson(kv.Value);
byte[] bytes = Encoding.UTF8.GetBytes(json);
AssignMapFile(song, kv.Key, fileName);
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, OnErr);
if (failed) { yield return Logout(); yield break; }
onProgress?.Invoke(0.95f);
// 5단계: 로그아웃
yield return Logout();
onProgress?.Invoke(1f);
onComplete?.Invoke();
Debug.Log($"[NasPublisher] '{song.title}' NAS 업로드 완료");
}
// ── DSM 인증 ─────────────────────────────────────────────
private IEnumerator Login(Action<string> onError)
{
Debug.Log($"[NasPublisher] 로그인 시도 — nasBaseUrl: '{nasBaseUrl}'");
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"DSM 로그인 실패: {req.error}");
yield break;
}
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 파싱 실패 — 계정 정보를 확인하세요");
}
private IEnumerator Logout()
{
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
_sid = "";
}
// ── 파일 업로드 ───────────────────────────────────────────
private IEnumerator UploadFile(string localPath, string nasFolder,
string fileName, Action<string> onError)
{
byte[] bytes = File.ReadAllBytes(localPath);
yield return UploadBytes(bytes, fileName, nasFolder, onError);
}
private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError)
{
Debug.Log($"[NasPublisher] 업로드 시도 — path: '{nasFolder}', file: '{fileName}'");
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)
{
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);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"업로드 실패({fileName}): {req.error}");
yield break;
}
Debug.Log($"[NasPublisher] 업로드 응답({fileName}): {req.downloadHandler.text}");
if (req.downloadHandler.text.Contains("\"success\":false"))
onError?.Invoke($"업로드 거부({fileName}): {req.downloadHandler.text}");
}
// ── songs.json 패치 ───────────────────────────────────────
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{
// 현재 songs.json 가져오기 (정적 서버에서 읽음)
SongsList songsList = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
{
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success)
{
songsList = JsonUtility.FromJson<SongsList>(req.downloadHandler.text);
}
}
if (songsList == null)
songsList = new SongsList { version = "1.0", songs = new System.Collections.Generic.List<SongInfo>() };
// 같은 id가 이미 있으면 교체, 없으면 추가
int existingIdx = songsList.songs.FindIndex(s => s.id == newSong.id);
if (existingIdx >= 0)
songsList.songs[existingIdx] = newSong;
else
songsList.songs.Add(newSong);
// 수정된 songs.json 업로드
byte[] jsonBytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(songsList, true));
yield return UploadBytes(jsonBytes, "songs.json", nasRootPath, onError);
}
// ── 유틸 ─────────────────────────────────────────────────
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
}
private static void AssignMapFile(SongInfo song, string difficulty, string fileName)
{
var info = song.difficulties.Get(difficulty);
if (info != null) info.mapFile = $"maps/{fileName}";
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: aed167b4baa5c114a8c3cb0471ec8fa0
-67
View File
@@ -1,67 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class NoteData
{
public float time; // 베어야 하는 시간
public int position; // 생성 위치 (0~3)
public int colorType; // 0: 빨강, 1: 파랑
}
[Serializable]
public class MapData
{
public List<NoteData> target;
}
// ── songs.json DTO ──────────────────────────────────────
[Serializable]
public class SongsList
{
public string version;
public List<SongInfo> songs;
}
[Serializable]
public class SongInfo
{
public string id;
public string title;
public string artist;
public float bpm;
public int duration;
public string audioFile;
public long audioSize;
public string coverImage;
public DifficultyMap difficulties;
public string addedAt;
}
[Serializable]
public class DifficultyMap
{
public DifficultyInfo normal;
public DifficultyInfo hard;
public DifficultyInfo expert;
public DifficultyInfo expertplus;
public DifficultyInfo Get(string key) => key switch
{
"normal" => normal,
"hard" => hard,
"expert" => expert,
"expertplus" => expertplus,
_ => null
};
}
[Serializable]
public class DifficultyInfo
{
public string mapFile;
public long mapSize;
public int noteCount;
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6f3255da09b9a2749b9a0369b640188c
-41
View File
@@ -1,41 +0,0 @@
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);
}
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0d94d5e288e7ee34facc8caf424e5c72
-25
View File
@@ -1,25 +0,0 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class SongCard : MonoBehaviour
{
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text artistText;
[SerializeField] private TMP_Text durationText;
[SerializeField] private GameObject downloadedBadge;
[SerializeField] private Button button;
public void Setup(SongInfo song, bool isDownloaded, Action onClick)
{
titleText.text = song.title;
artistText.text = song.artist;
durationText.text = FormatDuration(song.duration);
downloadedBadge.SetActive(isDownloaded);
button.onClick.AddListener(() => onClick?.Invoke());
}
private static string FormatDuration(int seconds)
=> $"{seconds / 60}:{seconds % 60:D2}";
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9eaee5b70034c244eb6060b8cdff58eb
-355
View File
@@ -1,355 +0,0 @@
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;
public class SongCreatorManager : MonoBehaviour
{
[Header("음원 선택")]
[SerializeField] private TMP_Dropdown audioDropdown;
[SerializeField] private Button refreshBtn;
[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;
[SerializeField] private TMP_InputField artistInput;
[SerializeField] private TMP_InputField bpmInput;
[Header("난이도")]
[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;
[SerializeField] private TMP_Text statusText;
[SerializeField] private Slider progressSlider;
[Header("연결")]
[SerializeField] private BeatSageUploader beatSageUploader;
[SerializeField] private NasPublisher nasPublisher;
[Header("씬")]
[SerializeField] private string mapEditorScene = "MapEditorScene";
// Quest: /sdcard/Android/data/{packageName}/files/input/ 로 ADB 복사
private static string InputPath =>
Path.Combine(Application.persistentDataPath, "input");
private readonly List<string> audioFiles = new();
private string _pendingFilePath; // 파일 다이얼로그 결과 (백그라운드 스레드 → 메인 스레드 전달)
// ── Unity ────────────────────────────────────────────────
private void Start()
{
Directory.CreateDirectory(InputPath);
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()
{
audioFiles.Clear();
audioDropdown.ClearOptions();
string[] files = Directory.GetFiles(InputPath, "*.mp3");
var options = new List<string>();
foreach (string f in files)
{
audioFiles.Add(f);
options.Add(Path.GetFileNameWithoutExtension(f));
}
if (options.Count == 0)
options.Add("-- .mp3 파일 없음 --");
audioDropdown.AddOptions(options);
}
// ── 생성 버튼 ────────────────────────────────────────────
private void OnGenerateClicked()
{
if (audioFiles.Count == 0) { SetStatus("음원 파일이 없습니다."); return; }
if (string.IsNullOrEmpty(titleInput.text)) { SetStatus("곡 제목을 입력해주세요."); return; }
if (!float.TryParse(bpmInput.text, out float bpm) || bpm <= 0)
{ SetStatus("BPM을 올바르게 입력해주세요."); return; }
var diffs = new List<string>();
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; }
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));
}
// ── 생성 플로우 ───────────────────────────────────────────
private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
{
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} ({(int)(p * 80)}%)");
},
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 업로드
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($"[4/4] NAS 업로드 중... ({(int)((0.8f + p * 0.2f) * 100)}%)");
},
onComplete: () =>
{
progressSlider.value = 1f;
SetStatus($"완료! '{song.title}' 생성 성공 (100%)");
Debug.Log($"[SongCreator] NAS 업로드 완료");
},
onError: err =>
{
Debug.LogError($"[SongCreator] NAS 오류: {err}");
SetStatus($"NAS 업로드 실패: {err}");
failed = true;
});
SetInteractable(true);
}
// ── 유틸 ─────────────────────────────────────────────────
private SongInfo BuildSongInfo(string audioPath, float bpm,
Dictionary<string, List<NoteData>> maps)
{
string id = titleInput.text.ToLower().Replace(" ", "_");
var diffMap = new DifficultyMap();
foreach (var kv in maps)
{
var info = new DifficultyInfo { noteCount = kv.Value.Count };
switch (kv.Key)
{
case "normal": diffMap.normal = info; break;
case "hard": diffMap.hard = info; break;
case "expert": diffMap.expert = info; break;
case "expertplus": diffMap.expertplus = info; break;
}
}
return new SongInfo
{
id = id,
title = titleInput.text,
artist = artistInput.text,
bpm = bpm,
audioFile = $"music/{id}.mp3",
difficulties = diffMap,
addedAt = DateTime.Now.ToString("yyyy-MM-dd")
};
}
private void SetStatus(string msg)
{
if (statusText != null) statusText.text = msg;
}
private void SetInteractable(bool value)
{
generateButton.interactable = value;
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;
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b5c960fcd6008a849ac5709213911f5d
-222
View File
@@ -1,222 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SongDetailPanel : MonoBehaviour
{
[Header("곡 정보")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text artistText;
[SerializeField] private TMP_Text infoText;
[Header("난이도 버튼")]
[SerializeField] private Button btnNormal;
[SerializeField] private Button btnHard;
[SerializeField] private Button btnExpert;
[SerializeField] private Button btnExpertPlus;
[Header("액션 버튼")]
[SerializeField] private Button downloadButton;
[SerializeField] private Button deleteButton;
[SerializeField] private Button playButton;
[SerializeField] private Button closeButton;
[Header("진행률")]
[SerializeField] private GameObject progressGroup;
[SerializeField] private Slider progressSlider;
[SerializeField] private TMP_Text progressText;
[Header("씬 이름")]
[SerializeField] private string gameSceneName = "Game";
private static readonly Color SelectedColor = new Color(0.4f, 0.8f, 1f);
private static readonly Color DeselectedColor = Color.white;
private SongInfo currentSong;
private string selectedDifficulty;
private DownloadManager downloadManager;
private SongSelectManager selectManager;
private readonly (string key, System.Func<SongDetailPanel, Button> btn)[] diffSlots =
{
("normal", p => p.btnNormal),
("hard", p => p.btnHard),
("expert", p => p.btnExpert),
("expertplus", p => p.btnExpertPlus),
};
// ── Public API ───────────────────────────────────────────
public void Show(SongInfo song, DownloadManager dm, SongSelectManager sm)
{
currentSong = song;
downloadManager = dm;
selectManager = sm;
selectedDifficulty = null;
titleText.text = song.title;
artistText.text = song.artist;
infoText.text = $"BPM {song.bpm} | {FormatDuration(song.duration)}";
RefreshUI();
}
// ── UI 갱신 ──────────────────────────────────────────────
private void RefreshUI()
{
bool downloaded = SongLibrary.Instance.IsSongDownloaded(currentSong.id);
// 난이도 버튼 세팅
foreach (var (key, getBtn) in diffSlots)
{
Button btn = getBtn(this);
bool exists = currentSong.difficulties.Get(key) != null;
btn.interactable = downloaded && exists;
btn.onClick.RemoveAllListeners();
if (downloaded && exists)
{
string captured = key;
btn.onClick.AddListener(() => SelectDifficulty(captured));
}
}
UpdateDiffColors();
// 액션 버튼
downloadButton.gameObject.SetActive(!downloaded);
deleteButton.gameObject.SetActive(downloaded);
playButton.interactable = downloaded && selectedDifficulty != null;
progressGroup.SetActive(false);
downloadButton.onClick.RemoveAllListeners();
downloadButton.onClick.AddListener(OnDownloadClicked);
deleteButton.onClick.RemoveAllListeners();
deleteButton.onClick.AddListener(OnDeleteClicked);
playButton.onClick.RemoveAllListeners();
playButton.onClick.AddListener(OnPlayClicked);
closeButton?.onClick.RemoveAllListeners();
closeButton?.onClick.AddListener(() => gameObject.SetActive(false));
}
private void SelectDifficulty(string difficulty)
{
selectedDifficulty = difficulty;
playButton.interactable = true;
UpdateDiffColors();
}
private void UpdateDiffColors()
{
foreach (var (key, getBtn) in diffSlots)
{
Button btn = getBtn(this);
var colors = btn.colors;
colors.normalColor = (key == selectedDifficulty) ? SelectedColor : DeselectedColor;
btn.colors = colors;
}
}
// ── 다운로드 (곡 단위 전체) ───────────────────────────────
private void OnDownloadClicked()
{
StartCoroutine(DownloadAllCoroutine());
}
private IEnumerator DownloadAllCoroutine()
{
// 존재하는 난이도 수집
var diffs = new List<string>();
foreach (var (key, _) in diffSlots)
if (currentSong.difficulties.Get(key) != null)
diffs.Add(key);
if (diffs.Count == 0) yield break;
SetInteractable(false);
progressGroup.SetActive(true);
int totalSteps = diffs.Count;
int doneSteps = 0;
bool failed = false;
foreach (string diff in diffs)
{
bool stepDone = false;
downloadManager.DownloadSong(
currentSong, diff,
onProgress: p =>
{
float overall = (doneSteps + p) / totalSteps;
progressSlider.value = overall;
progressText.text = $"{diffs[doneSteps < diffs.Count ? doneSteps : diffs.Count - 1].ToUpper()} {(int)(overall * 100)}%";
},
onComplete: () =>
{
SongLibrary.Instance.MarkDownloaded(currentSong.id, diff);
doneSteps++;
stepDone = true;
},
onError: err =>
{
Debug.LogError($"[SongDetailPanel] {err}");
failed = true;
stepDone = true;
});
yield return new WaitUntil(() => stepDone);
if (failed) break;
}
SetInteractable(true);
selectManager.RefreshCards();
RefreshUI();
if (!failed)
Debug.Log($"[SongDetailPanel] '{currentSong.title}' 전체 다운로드 완료");
}
// ── 삭제 ─────────────────────────────────────────────────
private void OnDeleteClicked()
{
downloadManager.DeleteSong(currentSong.id);
SongLibrary.Instance.MarkSongRemoved(currentSong.id);
selectedDifficulty = null;
selectManager.RefreshCards();
RefreshUI();
}
// ── 플레이 ───────────────────────────────────────────────
private void OnPlayClicked()
{
GameSession.SelectedSong = currentSong;
GameSession.SelectedDifficulty = selectedDifficulty;
SceneManager.LoadScene(gameSceneName);
}
// ── 유틸 ─────────────────────────────────────────────────
private void SetInteractable(bool value)
{
downloadButton.interactable = value;
deleteButton.interactable = value;
playButton.interactable = value && selectedDifficulty != null;
foreach (var (_, getBtn) in diffSlots)
getBtn(this).interactable = value;
}
private static string FormatDuration(int seconds)
=> $"{seconds / 60}:{seconds % 60:D2}";
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7486780aac3b88249b9d78712ed72637
-153
View File
@@ -1,153 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
// 로컬에 다운로드된 곡 목록을 추적하고 persistentDataPath에 저장
public class SongLibrary : MonoBehaviour
{
public static SongLibrary Instance { get; private set; }
private const string FileName = "song_library.json";
private static string SavePath => Path.Combine(Application.persistentDataPath, FileName);
private LibraryData _data = new LibraryData();
// ── Unity ────────────────────────────────────────────────
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
Load();
}
// ── Public API ───────────────────────────────────────────
// 다운로드 완료 시 호출
public void MarkDownloaded(string songId, string difficulty)
{
LibraryEntry entry = GetOrCreate(songId);
if (!entry.difficulties.Contains(difficulty))
entry.difficulties.Add(difficulty);
entry.lastAccessedAt = DateTime.UtcNow.ToString("o");
Save();
}
// 난이도 삭제 시 호출
public void MarkDifficultyRemoved(string songId, string difficulty)
{
LibraryEntry entry = Find(songId);
if (entry == null) return;
entry.difficulties.Remove(difficulty);
if (entry.difficulties.Count == 0)
_data.entries.Remove(entry);
Save();
}
// 곡 전체 삭제 시 호출
public void MarkSongRemoved(string songId)
{
_data.entries.RemoveAll(e => e.songId == songId);
Save();
}
// 플레이 시작 시 호출 → LRU 갱신
public void TouchSong(string songId)
{
LibraryEntry entry = Find(songId);
if (entry == null) return;
entry.lastAccessedAt = DateTime.UtcNow.ToString("o");
Save();
}
public bool IsSongDownloaded(string songId)
=> Find(songId) != null;
public bool IsDifficultyDownloaded(string songId, string difficulty)
=> Find(songId)?.difficulties.Contains(difficulty) ?? false;
public List<LibraryEntry> GetAll()
=> _data.entries;
// 앱 시작 시 실제 파일과 비교해 유효성 검사
public void ValidateWithFileSystem(DownloadManager dm, List<SongInfo> songs)
{
bool dirty = false;
foreach (SongInfo song in songs)
{
LibraryEntry entry = Find(song.id);
if (entry == null) continue;
// 오디오 파일이 없으면 항목 전체 제거
if (!dm.IsSongDownloaded(song.id))
{
_data.entries.Remove(entry);
dirty = true;
continue;
}
// 없어진 난이도 맵 파일 제거
entry.difficulties.RemoveAll(d => !dm.IsDifficultyDownloaded(song, d));
if (entry.difficulties.Count == 0)
{
_data.entries.Remove(entry);
dirty = true;
}
}
if (dirty) Save();
}
// ── 내부 구현 ─────────────────────────────────────────────
private LibraryEntry Find(string songId)
=> _data.entries.Find(e => e.songId == songId);
private LibraryEntry GetOrCreate(string songId)
{
LibraryEntry entry = Find(songId);
if (entry != null) return entry;
entry = new LibraryEntry { songId = songId };
_data.entries.Add(entry);
return entry;
}
private void Load()
{
if (!File.Exists(SavePath)) return;
try
{
string json = File.ReadAllText(SavePath);
_data = JsonUtility.FromJson<LibraryData>(json) ?? new LibraryData();
}
catch (Exception e)
{
Debug.LogWarning($"[SongLibrary] 로드 실패, 초기화: {e.Message}");
_data = new LibraryData();
}
}
private void Save()
{
File.WriteAllText(SavePath, JsonUtility.ToJson(_data, true));
}
}
// ── 직렬화 데이터 구조 ─────────────────────────────────────
[Serializable]
public class LibraryData
{
public List<LibraryEntry> entries = new List<LibraryEntry>();
}
[Serializable]
public class LibraryEntry
{
public string songId;
public List<string> difficulties = new List<string>();
public string lastAccessedAt;
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 18ce3ae50fe8ac74498664297350d5f0
-136
View File
@@ -1,136 +0,0 @@
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SongSelectManager : MonoBehaviour
{
[Header("탭 버튼")]
[SerializeField] private Button tabAllBtn;
[SerializeField] private Button tabOwnedBtn;
[SerializeField] private Color tabActiveColor = Color.white;
[SerializeField] private Color tabInactiveColor = new Color(0.6f, 0.6f, 0.6f);
[Header("카드 목록")]
[SerializeField] private Transform cardContainer;
[SerializeField] private GameObject songCardPrefab;
[Header("연결")]
[SerializeField] private SongDetailPanel detailPanel;
[SerializeField] private DownloadManager downloadManager;
[Header("상태 오버레이")]
[SerializeField] private GameObject loadingOverlay;
[SerializeField] private GameObject errorOverlay;
[SerializeField] private TMP_Text errorText;
private static string CachePath =>
Path.Combine(Application.persistentDataPath, "songs_cache.json");
private List<SongInfo> allSongs = new List<SongInfo>();
private bool showingOwned = false;
// ── Unity ────────────────────────────────────────────────
private void Start()
{
tabAllBtn.onClick.AddListener(() => SwitchTab(false));
tabOwnedBtn.onClick.AddListener(() => SwitchTab(true));
detailPanel.gameObject.SetActive(false);
SetTabVisual(false);
FetchSongs();
}
// ── 탭 전환 ──────────────────────────────────────────────
private void SwitchTab(bool owned)
{
showingOwned = owned;
SetTabVisual(owned);
RefreshCards();
}
private void SetTabVisual(bool owned)
{
tabAllBtn.image.color = owned ? tabInactiveColor : tabActiveColor;
tabOwnedBtn.image.color = owned ? tabActiveColor : tabInactiveColor;
}
// ── 데이터 로드 ───────────────────────────────────────────
private void FetchSongs()
{
loadingOverlay.SetActive(true);
errorOverlay.SetActive(false);
downloadManager.FetchSongsList(
onSuccess: list =>
{
allSongs = list.songs;
SaveCache(list);
SongLibrary.Instance.ValidateWithFileSystem(downloadManager, list.songs);
loadingOverlay.SetActive(false);
RefreshCards();
},
onError: _ =>
{
SongsList cached = LoadCache();
loadingOverlay.SetActive(false);
if (cached != null)
{
allSongs = cached.songs;
RefreshCards();
}
else
{
errorOverlay.SetActive(true);
errorText.text = "서버 연결 실패\n인터넷 연결을 확인해주세요";
}
});
}
// ── 카드 갱신 ────────────────────────────────────────────
public void RefreshCards()
{
foreach (Transform child in cardContainer)
Destroy(child.gameObject);
List<SongInfo> songs = showingOwned
? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id))
: allSongs;
foreach (SongInfo song in songs)
{
bool downloaded = SongLibrary.Instance.IsSongDownloaded(song.id);
GameObject obj = Instantiate(songCardPrefab, cardContainer);
SongInfo captured = song;
obj.GetComponent<SongCard>().Setup(song, downloaded, () => OnCardClicked(captured));
}
}
private void OnCardClicked(SongInfo song)
{
detailPanel.gameObject.SetActive(true);
detailPanel.Show(song, downloadManager, this);
}
// ── 로컬 캐시 (오프라인 폴백) ─────────────────────────────
private static void SaveCache(SongsList list)
{
try { File.WriteAllText(CachePath, JsonUtility.ToJson(list, true)); }
catch { }
}
private static SongsList LoadCache()
{
if (!File.Exists(CachePath)) return null;
try { return JsonUtility.FromJson<SongsList>(File.ReadAllText(CachePath)); }
catch { return null; }
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: df07151c232d3dd4fa61664629d53203
-197
View File
@@ -1,197 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
public class Spawner : MonoBehaviour
{
[Header("오디오 및 파일 설정")]
public AudioSource audioSource;
[Header("노트 프리팹 & 위치")]
public GameObject[] cubePrefabs;
public Transform[] spawnPoints;
[Header("타이밍 설정")]
public float noteSpeed = 2.0f;
public float distanceToHit = 10.0f;
[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;
private bool isReady = false;
// ── Unity ────────────────────────────────────────────────
void Awake()
{
// PlayOnAwake가 OnEnable에서 발동하기 전에 차단
if (audioSource != null)
{
audioSource.playOnAwake = false;
audioSource.clip = null;
}
}
void Start()
{
travelTime = distanceToHit / noteSpeed;
if (GameSession.SelectedSong == null)
{
Debug.LogWarning("[Spawner] 선택된 곡 없음 → 곡 선택 화면으로 이동");
SceneManager.LoadScene(songSelectSceneName);
return;
}
StartCoroutine(InitGame());
}
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;
while (nextNoteIndex < mapNotes.Count &&
currentTime + travelTime >= mapNotes[nextNoteIndex].time)
{
SpawnNote(mapNotes[nextNoteIndex]);
nextNoteIndex++;
}
}
// ── 초기화 ───────────────────────────────────────────────
private IEnumerator InitGame()
{
SongInfo song = GameSession.SelectedSong;
string difficulty = GameSession.SelectedDifficulty;
// ── 기본 연결 확인 ───────────────────────────────────────
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}\n→ SongSelect에서 다운로드하세요");
yield break;
}
LoadMapJson(mapPath);
// ── 오디오 로드 ──────────────────────────────────────────
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);
if (audioSource.clip == null)
{
Debug.LogError("[Spawner] 오디오 클립 로드 실패 (파일 손상 또는 형식 오류)");
yield break;
}
SongLibrary.Instance?.TouchSong(song.id);
isReady = true;
audioSource.Play();
Debug.Log($"[Spawner] 재생 시작: {song.title} / {difficulty} / 노트:{mapNotes.Count}개");
}
private void LoadMapJson(string path)
{
string json = File.ReadAllText(path);
MapData data = JsonUtility.FromJson<MapData>(json);
mapNotes = data?.target ?? new List<NoteData>();
mapNotes.Sort((a, b) => a.time.CompareTo(b.time));
Debug.Log($"[Spawner] 노트 로드: {mapNotes.Count}개");
}
private IEnumerator LoadAudioClip(string path)
{
string uri = "file://" + path;
using var req = UnityWebRequestMultimedia.GetAudioClip(uri, AudioType.MPEG);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Spawner] 오디오 로드 실패: {req.error}");
yield break;
}
audioSource.clip = DownloadHandlerAudioClip.GetContent(req);
}
// ── 노트 스폰 ────────────────────────────────────────────
private void SpawnNote(NoteData data)
{
if (data.colorType >= cubePrefabs.Length || data.position >= spawnPoints.Length) return;
GameObject obj = Instantiate(
cubePrefabs[data.colorType],
spawnPoints[data.position].position,
spawnPoints[data.position].rotation);
obj.transform.Rotate(transform.forward, 90 * Random.Range(0, 4));
}
// ── 경로 헬퍼 ────────────────────────────────────────────
private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber");
private static string GetAudioPath(string songId) =>
Path.Combine(CacheRoot, songId, $"{songId}.mp3");
private static string GetMapPath(SongInfo song, string difficulty)
{
DifficultyInfo info = song.difficulties.Get(difficulty);
if (info == null) return string.Empty;
return Path.Combine(CacheRoot, song.id, Path.GetFileName(info.mapFile));
}
}
-37
View File
@@ -1,37 +0,0 @@
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
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7e76ce582f56913438ba761e845a91e0
File diff suppressed because one or more lines are too long
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 9c7dde644b3daab47be8b256a93c37a7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: fddd718382b14ec4497b863e62f02e8f
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: b2914d4b38e00aa41a38dd8200e2121e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-65
View File
@@ -1,65 +0,0 @@
# CLAUDE.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
@@ -74,8 +74,4 @@ crashlytics-build.properties
/[Aa]ssets/[Ss]treamingAssets/aa.meta /[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/* /[Aa]ssets/[Ss]treamingAssets/aa/*
# End of https://www.toptal.com/developers/gitignore/api/unity # 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

Some files were not shown because too many files have changed in this diff Show More