diff --git a/.gitignore b/.gitignore index 8533488..10ad2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,8 @@ crashlytics-build.properties /[Aa]ssets/[Ss]treamingAssets/aa.meta /[Aa]ssets/[Ss]treamingAssets/aa/* -# End of https://www.toptal.com/developers/gitignore/api/unity \ No newline at end of file +# 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 \ No newline at end of file diff --git a/Assets/Script/BeatSageUploader.cs b/Assets/Script/BeatSageUploader.cs index 3863bce..e076b91 100644 --- a/Assets/Script/BeatSageUploader.cs +++ b/Assets/Script/BeatSageUploader.cs @@ -20,21 +20,22 @@ public class BeatSageUploader : MonoBehaviour private const float POLL_TIMEOUT = 300f; // Beat Sage 난이도 이름 매핑 (내부 → API) + // Beat Sage API가 인정하는 난이도: Normal, Hard, Expert, ExpertPlus (Easy 없음) private static readonly Dictionary DiffNames = new() { - { "easy", "Easy" }, - { "normal", "Normal" }, - { "hard", "Hard" }, - { "expert", "Expert" }, + { "normal", "Normal" }, + { "hard", "Hard" }, + { "expert", "Expert" }, + { "expertplus", "ExpertPlus" }, }; // Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명) private static readonly Dictionary DatFileNames = new() { - { "easy", "Easy.dat" }, - { "normal", "Normal.dat" }, - { "hard", "Hard.dat" }, - { "expert", "Expert.dat" }, + { "normal", "Normal.dat" }, + { "hard", "Hard.dat" }, + { "expert", "Expert.dat" }, + { "expertplus", "ExpertPlus.dat" }, }; public string CurrentStatus { get; private set; } = ""; @@ -50,18 +51,20 @@ public class BeatSageUploader : MonoBehaviour Action onError) { // 1단계: 레벨 생성 요청 - SetStatus("Beat Sage에 음원 전송 중..."); + SetStatus("[1/4] 음원 업로드 중..."); + Debug.Log($"[BeatSage] 업로드 시작 — 파일: {audioPath}"); string levelId = null; yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError); + Debug.Log($"[BeatSage] CreateLevel 완료 — levelId: {levelId}"); if (levelId == null) yield break; onProgress?.Invoke(0.15f); // 2단계: 생성 완료 폴링 - SetStatus("Beat Sage AI 맵 생성 중..."); + SetStatus("[2/4] AI 맵 생성 시작..."); bool ready = false; float elapsed = 0f; @@ -74,8 +77,9 @@ public class BeatSageUploader : MonoBehaviour yield return PollHeartbeat(levelId, status => { - ready = status == "generated"; - error = status == "error"; + ready = string.Equals(status, "generated", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "done", System.StringComparison.OrdinalIgnoreCase); + error = string.Equals(status, "error", System.StringComparison.OrdinalIgnoreCase); }, onError); @@ -83,13 +87,13 @@ public class BeatSageUploader : MonoBehaviour float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT); onProgress?.Invoke(0.15f + progress * 0.6f); - SetStatus($"Beat Sage AI 맵 생성 중... ({(int)elapsed}s)"); + SetStatus($"[2/4] AI 맵 생성 중... {(int)elapsed}초 경과"); } if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; } // 3단계: .zip 다운로드 - SetStatus("결과 다운로드 중..."); + SetStatus("[3/4] 결과 다운로드 중..."); byte[] zipBytes = null; yield return DownloadZip(levelId, @@ -100,7 +104,7 @@ public class BeatSageUploader : MonoBehaviour onProgress?.Invoke(0.9f); // 4단계: .zip 해제 + BeatSageConverter 변환 - SetStatus("맵 데이터 변환 중..."); + SetStatus("[3/4] 맵 데이터 변환 중..."); Dictionary> maps = null; try @@ -114,7 +118,7 @@ public class BeatSageUploader : MonoBehaviour } onProgress?.Invoke(1f); - SetStatus("변환 완료"); + SetStatus("[3/4] 변환 완료"); onComplete?.Invoke(maps); } @@ -126,15 +130,25 @@ public class BeatSageUploader : MonoBehaviour byte[] audioBytes = File.ReadAllBytes(audioPath); string fileName = Path.GetFileName(audioPath); - // 난이도 문자열 변환: ["easy","hard"] → "Easy,Hard" - var diffStr = string.Join(",", difficulties.ConvertAll(d => - DiffNames.TryGetValue(d, out var n) ? n : d)); + // 난이도: 알 수 없는 값(easy 등)은 건너뜀, 쉼표 구분 단일 필드로 전송 + var mappedDiffs = new List(); + 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 { new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"), - new MultipartFormDataSection("audio_metadata_title", ""), - new MultipartFormDataSection("audio_metadata_artist", ""), + new MultipartFormDataSection("audio_metadata_title", " "), + new MultipartFormDataSection("audio_metadata_artist", " "), new MultipartFormDataSection("difficulties", diffStr), new MultipartFormDataSection("modes", "Standard"), new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"), @@ -149,6 +163,8 @@ public class BeatSageUploader : MonoBehaviour if (req.result != UnityWebRequest.Result.Success) { + string body = req.downloadHandler?.text ?? "(응답 없음)"; + Debug.LogError($"[BeatSage] HTTP {req.responseCode} — {req.error}\n응답 본문: {body}"); onError?.Invoke($"레벨 생성 요청 실패: {req.error}"); yield break; } diff --git a/Assets/Script/DesktopUIMode.cs b/Assets/Script/DesktopUIMode.cs new file mode 100644 index 0000000..10d95ef --- /dev/null +++ b/Assets/Script/DesktopUIMode.cs @@ -0,0 +1,161 @@ +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.SceneManagement; +using UnityEngine.UI; + +/// +/// 에디터/PC 환경 전용 테스트 보조 스크립트. +/// RuntimeInitializeOnLoadMethod로 자동 생성되므로 씬에 직접 추가할 필요 없습니다. +/// Quest 빌드 시 자동으로 비활성화됩니다. +/// +/// 기능: +/// 1. TrackedDeviceGraphicRaycaster → GraphicRaycaster 교체 (마우스 클릭 활성화) +/// 2. Canvas EventCamera 자동 갱신 (클릭 위치 정확도) +/// 3. ESC 키로 씬별 뒤로가기 +/// +public class DesktopUIMode : MonoBehaviour +{ +#if !UNITY_ANDROID || UNITY_EDITOR + + // 어떤 씬에서 Play를 눌러도 자동 실행 + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void AutoCreate() + { + if (FindObjectOfType() != null) return; // 이미 있으면 생성 안 함 + + var go = new GameObject("[DesktopUIMode]"); + go.AddComponent(); + } + + // ESC 뒤로가기 씬 매핑 + private static readonly System.Collections.Generic.Dictionary BackSceneMap = + new System.Collections.Generic.Dictionary + { + { "SongSelect", "Intro" }, + { "SongCreator", "Intro" }, + { "MapEditorScene", "SongCreator" }, + { "Game", "SongSelect" }, + }; + + private void Awake() + { + // 중복 방지 (씬에 직접 놓은 경우 AutoCreate와 겹칠 수 있음) + if (FindObjectsOfType().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()) + { + if (canvas.renderMode != RenderMode.WorldSpace) continue; + + // TrackedDeviceGraphicRaycaster → GraphicRaycaster + var tracked = canvas.GetComponent("TrackedDeviceGraphicRaycaster"); + if (tracked != null) + { + DestroyImmediate(tracked); + if (canvas.GetComponent() == null) + canvas.gameObject.AddComponent(); + + Debug.Log($"[DesktopUIMode] {canvas.name} Raycaster 교체 완료"); + } + } + + RemoveDuplicateAudioListeners(); + RefreshCanvasCameras(); + } + + private static void RemoveDuplicateAudioListeners() + { + var listeners = FindObjectsOfType(); + 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()) + { + if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad") + { + cam = c; + break; + } + } + } + if (cam == null) cam = FindObjectOfType(); // 최후 수단 + if (cam == null) return; + + foreach (var canvas in FindObjectsOfType()) + { + 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 +} diff --git a/Assets/Script/DesktopUIMode.cs.meta b/Assets/Script/DesktopUIMode.cs.meta new file mode 100644 index 0000000..a4b632b --- /dev/null +++ b/Assets/Script/DesktopUIMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c063d20f87d41d40a6a01c6bd1a1736 \ No newline at end of file diff --git a/Assets/Script/DownloadManager.cs b/Assets/Script/DownloadManager.cs index 86511c2..fa92fbb 100644 --- a/Assets/Script/DownloadManager.cs +++ b/Assets/Script/DownloadManager.cs @@ -6,7 +6,7 @@ using UnityEngine.Networking; public class DownloadManager : MonoBehaviour { - [SerializeField] private string baseUrl = "http://whdwo798.synology.me:8180/beatsaber"; + [SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber"; private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber"); @@ -64,8 +64,10 @@ public class DownloadManager : MonoBehaviour public string MapPath(SongInfo song, string difficulty) { DifficultyInfo info = song.difficulties.Get(difficulty); - if (info == null) return null; - return Path.Combine(SongDir(song.id), Path.GetFileName(info.mapFile)); + if (info == null || string.IsNullOrEmpty(info.mapFile)) return null; + string fileName = Path.GetFileName(info.mapFile); + if (string.IsNullOrEmpty(fileName)) return null; + return Path.Combine(SongDir(song.id), fileName); } // ── 내부 구현 ───────────────────────────────────────────── @@ -73,10 +75,11 @@ public class DownloadManager : MonoBehaviour private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty, Action onProgress, Action onComplete, Action onError) { - Directory.CreateDirectory(SongDir(song.id)); + string songDir = Path.GetFullPath(SongDir(song.id)); + Directory.CreateDirectory(songDir); // 1단계: 오디오 (70%) - string audioPath = AudioPath(song.id); + string audioPath = Path.Combine(songDir, $"{song.id}.mp3"); if (!File.Exists(audioPath)) { bool failed = false; @@ -95,7 +98,19 @@ public class DownloadManager : MonoBehaviour yield break; } + if (string.IsNullOrEmpty(diffInfo.mapFile)) + { + onError?.Invoke($"'{difficulty}' 맵 파일 정보 없음 — Creator에서 곡을 다시 생성해주세요"); + yield break; + } + string mapPath = MapPath(song, difficulty); + if (mapPath != null) mapPath = Path.GetFullPath(mapPath); + if (mapPath == null) + { + onError?.Invoke($"'{difficulty}' 맵 경로 계산 실패"); + yield break; + } if (!File.Exists(mapPath)) { bool failed = false; diff --git a/Assets/Script/GameBackButton.cs b/Assets/Script/GameBackButton.cs new file mode 100644 index 0000000..decaaa4 --- /dev/null +++ b/Assets/Script/GameBackButton.cs @@ -0,0 +1,18 @@ +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UI; + +/// +/// Game 씬의 뒤로가기 버튼에 자동으로 추가됩니다. +/// SceneBuilder가 Button에 이 컴포넌트를 달아 씬 이동을 처리합니다. +/// +[RequireComponent(typeof(Button))] +public class GameBackButton : MonoBehaviour +{ + [SerializeField] private string targetScene = "SongSelect"; + + private void Start() + { + GetComponent