노래 만들기 수정 — NAS 업로드 완성, Easy 제거 및 ExpertPlus 추가, 다운로드 버그 수정

- NasPublisher: DSM 7.2 multipart body 수동 구성으로 업로드 401 오류 해결
- NasPublisher: 비밀번호 StreamingAssets/nas_config.json 분리, .gitignore 등록
- NasPublisher: staticBaseUrl 포트 8180 → 80 수정
- BeatSageUploader: Easy 난이도 제거, ExpertPlus(.dat) 추가
- NoteData: DifficultyMap에서 easy 제거, expertplus 추가
- SongCreatorManager: toggleEasy → toggleExpertPlus 교체
- SongDetailPanel: btnEasy → btnExpertPlus 교체
- DownloadManager: DownloadHandlerFile 경로 정규화(Path.GetFullPath), mapFile 빈 값 방어 처리
- PersistentXRRig: FindObjectsOfType obsolete 경고 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:39:27 +09:00
parent 2cd1be88d4
commit c73ff7f412
15 changed files with 481 additions and 93 deletions
+4
View File
@@ -75,3 +75,7 @@ crashlytics-build.properties
/[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
+29 -13
View File
@@ -20,21 +20,22 @@ public class BeatSageUploader : MonoBehaviour
private const float POLL_TIMEOUT = 300f; private const float POLL_TIMEOUT = 300f;
// Beat Sage 난이도 이름 매핑 (내부 → API) // Beat Sage 난이도 이름 매핑 (내부 → API)
// Beat Sage API가 인정하는 난이도: Normal, Hard, Expert, ExpertPlus (Easy 없음)
private static readonly Dictionary<string, string> DiffNames = new() private static readonly Dictionary<string, string> DiffNames = new()
{ {
{ "easy", "Easy" },
{ "normal", "Normal" }, { "normal", "Normal" },
{ "hard", "Hard" }, { "hard", "Hard" },
{ "expert", "Expert" }, { "expert", "Expert" },
{ "expertplus", "ExpertPlus" },
}; };
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명) // Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
private static readonly Dictionary<string, string> DatFileNames = new() private static readonly Dictionary<string, string> DatFileNames = new()
{ {
{ "easy", "Easy.dat" },
{ "normal", "Normal.dat" }, { "normal", "Normal.dat" },
{ "hard", "Hard.dat" }, { "hard", "Hard.dat" },
{ "expert", "Expert.dat" }, { "expert", "Expert.dat" },
{ "expertplus", "ExpertPlus.dat" },
}; };
public string CurrentStatus { get; private set; } = ""; public string CurrentStatus { get; private set; } = "";
@@ -50,18 +51,20 @@ public class BeatSageUploader : MonoBehaviour
Action<string> onError) Action<string> onError)
{ {
// 1단계: 레벨 생성 요청 // 1단계: 레벨 생성 요청
SetStatus("Beat Sage에 음원 전송 중..."); SetStatus("[1/4] 음원 업로드 중...");
Debug.Log($"[BeatSage] 업로드 시작 — 파일: {audioPath}");
string levelId = null; string levelId = null;
yield return CreateLevel(audioPath, difficulties, yield return CreateLevel(audioPath, difficulties,
id => levelId = id, id => levelId = id,
onError); onError);
Debug.Log($"[BeatSage] CreateLevel 완료 — levelId: {levelId}");
if (levelId == null) yield break; if (levelId == null) yield break;
onProgress?.Invoke(0.15f); onProgress?.Invoke(0.15f);
// 2단계: 생성 완료 폴링 // 2단계: 생성 완료 폴링
SetStatus("Beat Sage AI 맵 생성 ..."); SetStatus("[2/4] AI 맵 생성 시작...");
bool ready = false; bool ready = false;
float elapsed = 0f; float elapsed = 0f;
@@ -74,8 +77,9 @@ public class BeatSageUploader : MonoBehaviour
yield return PollHeartbeat(levelId, yield return PollHeartbeat(levelId,
status => status =>
{ {
ready = status == "generated"; ready = string.Equals(status, "generated", System.StringComparison.OrdinalIgnoreCase)
error = status == "error"; || string.Equals(status, "done", System.StringComparison.OrdinalIgnoreCase);
error = string.Equals(status, "error", System.StringComparison.OrdinalIgnoreCase);
}, },
onError); onError);
@@ -83,13 +87,13 @@ public class BeatSageUploader : MonoBehaviour
float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT); float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT);
onProgress?.Invoke(0.15f + progress * 0.6f); 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; } if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; }
// 3단계: .zip 다운로드 // 3단계: .zip 다운로드
SetStatus("결과 다운로드 중..."); SetStatus("[3/4] 결과 다운로드 중...");
byte[] zipBytes = null; byte[] zipBytes = null;
yield return DownloadZip(levelId, yield return DownloadZip(levelId,
@@ -100,7 +104,7 @@ public class BeatSageUploader : MonoBehaviour
onProgress?.Invoke(0.9f); onProgress?.Invoke(0.9f);
// 4단계: .zip 해제 + BeatSageConverter 변환 // 4단계: .zip 해제 + BeatSageConverter 변환
SetStatus("맵 데이터 변환 중..."); SetStatus("[3/4] 맵 데이터 변환 중...");
Dictionary<string, List<NoteData>> maps = null; Dictionary<string, List<NoteData>> maps = null;
try try
@@ -114,7 +118,7 @@ public class BeatSageUploader : MonoBehaviour
} }
onProgress?.Invoke(1f); onProgress?.Invoke(1f);
SetStatus("변환 완료"); SetStatus("[3/4] 변환 완료");
onComplete?.Invoke(maps); onComplete?.Invoke(maps);
} }
@@ -126,9 +130,19 @@ public class BeatSageUploader : MonoBehaviour
byte[] audioBytes = File.ReadAllBytes(audioPath); byte[] audioBytes = File.ReadAllBytes(audioPath);
string fileName = Path.GetFileName(audioPath); string fileName = Path.GetFileName(audioPath);
// 난이도 문자열 변환: ["easy","hard"] → "Easy,Hard" // 난이도: 알 수 없는 값(easy 등)은 건너뜀, 쉼표 구분 단일 필드로 전송
var diffStr = string.Join(",", difficulties.ConvertAll(d => var mappedDiffs = new List<string>();
DiffNames.TryGetValue(d, out var n) ? n : d)); 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> var form = new List<IMultipartFormSection>
{ {
@@ -149,6 +163,8 @@ public class BeatSageUploader : MonoBehaviour
if (req.result != UnityWebRequest.Result.Success) 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}"); onError?.Invoke($"레벨 생성 요청 실패: {req.error}");
yield break; yield break;
} }
+161
View File
@@ -0,0 +1,161 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
/// <summary>
/// 에디터/PC 환경 전용 테스트 보조 스크립트.
/// RuntimeInitializeOnLoadMethod로 자동 생성되므로 씬에 직접 추가할 필요 없습니다.
/// Quest 빌드 시 자동으로 비활성화됩니다.
///
/// 기능:
/// 1. TrackedDeviceGraphicRaycaster → GraphicRaycaster 교체 (마우스 클릭 활성화)
/// 2. Canvas EventCamera 자동 갱신 (클릭 위치 정확도)
/// 3. ESC 키로 씬별 뒤로가기
/// </summary>
public class DesktopUIMode : MonoBehaviour
{
#if !UNITY_ANDROID || UNITY_EDITOR
// 어떤 씬에서 Play를 눌러도 자동 실행
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoCreate()
{
if (FindObjectOfType<DesktopUIMode>() != null) return; // 이미 있으면 생성 안 함
var go = new GameObject("[DesktopUIMode]");
go.AddComponent<DesktopUIMode>();
}
// ESC 뒤로가기 씬 매핑
private static readonly System.Collections.Generic.Dictionary<string, string> BackSceneMap =
new System.Collections.Generic.Dictionary<string, string>
{
{ "SongSelect", "Intro" },
{ "SongCreator", "Intro" },
{ "MapEditorScene", "SongCreator" },
{ "Game", "SongSelect" },
};
private void Awake()
{
// 중복 방지 (씬에 직접 놓은 경우 AutoCreate와 겹칠 수 있음)
if (FindObjectsOfType<DesktopUIMode>().Length > 1)
{
Destroy(gameObject);
return;
}
DontDestroyOnLoad(gameObject);
SceneManager.sceneLoaded += OnSceneLoaded;
PatchCanvases(); // 현재 씬 즉시 패치
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
StartCoroutine(PatchAfterFrame());
}
private System.Collections.IEnumerator PatchAfterFrame()
{
yield return null; // Canvas.Awake 이후 실행 보장
PatchCanvases();
}
private void Update()
{
RefreshCanvasCameras(); // 매 프레임 worldCamera 갱신 (카메라 초기화 타이밍 보정)
if (Keyboard.current?.escapeKey.wasPressedThisFrame == true)
GoBack();
}
// ── Canvas 패치 ──────────────────────────────────────────
private static void PatchCanvases()
{
foreach (var canvas in FindObjectsOfType<Canvas>())
{
if (canvas.renderMode != RenderMode.WorldSpace) continue;
// TrackedDeviceGraphicRaycaster → GraphicRaycaster
var tracked = canvas.GetComponent("TrackedDeviceGraphicRaycaster");
if (tracked != null)
{
DestroyImmediate(tracked);
if (canvas.GetComponent<GraphicRaycaster>() == null)
canvas.gameObject.AddComponent<GraphicRaycaster>();
Debug.Log($"[DesktopUIMode] {canvas.name} Raycaster 교체 완료");
}
}
RemoveDuplicateAudioListeners();
RefreshCanvasCameras();
}
private static void RemoveDuplicateAudioListeners()
{
var listeners = FindObjectsOfType<AudioListener>();
if (listeners.Length <= 1) return;
// 첫 번째(DontDestroyOnLoad에 없는 것 우선)만 남기고 나머지 제거
AudioListener keep = null;
foreach (var al in listeners)
{
if (al.gameObject.scene.name != "DontDestroyOnLoad")
{ keep = al; break; }
}
if (keep == null) keep = listeners[0];
foreach (var al in listeners)
{
if (al != keep)
{
Debug.Log($"[DesktopUIMode] 중복 AudioListener 제거: {al.gameObject.name}");
DestroyImmediate(al);
}
}
}
private static void RefreshCanvasCameras()
{
Camera cam = Camera.main;
if (cam == null)
{
// Camera.main이 없으면 씬에서 직접 찾기 (DontDestroyOnLoad 제외)
foreach (var c in FindObjectsOfType<Camera>())
{
if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad")
{
cam = c;
break;
}
}
}
if (cam == null) cam = FindObjectOfType<Camera>(); // 최후 수단
if (cam == null) return;
foreach (var canvas in FindObjectsOfType<Canvas>())
{
if (canvas.renderMode == RenderMode.WorldSpace && canvas.worldCamera != cam)
canvas.worldCamera = cam;
}
}
// ── ESC 뒤로가기 ─────────────────────────────────────────
private static void GoBack()
{
string current = SceneManager.GetActiveScene().name;
if (BackSceneMap.TryGetValue(current, out string target))
SceneManager.LoadScene(target);
}
#endif
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1c063d20f87d41d40a6a01c6bd1a1736
+20 -5
View File
@@ -6,7 +6,7 @@ using UnityEngine.Networking;
public class DownloadManager : MonoBehaviour 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"); private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
@@ -64,8 +64,10 @@ public class DownloadManager : MonoBehaviour
public string MapPath(SongInfo song, string difficulty) public string MapPath(SongInfo song, string difficulty)
{ {
DifficultyInfo info = song.difficulties.Get(difficulty); DifficultyInfo info = song.difficulties.Get(difficulty);
if (info == null) return null; if (info == null || string.IsNullOrEmpty(info.mapFile)) return null;
return Path.Combine(SongDir(song.id), Path.GetFileName(info.mapFile)); 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, private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError) Action<float> onProgress, Action onComplete, Action<string> onError)
{ {
Directory.CreateDirectory(SongDir(song.id)); string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
// 1단계: 오디오 (70%) // 1단계: 오디오 (70%)
string audioPath = AudioPath(song.id); string audioPath = Path.Combine(songDir, $"{song.id}.mp3");
if (!File.Exists(audioPath)) if (!File.Exists(audioPath))
{ {
bool failed = false; bool failed = false;
@@ -95,7 +98,19 @@ public class DownloadManager : MonoBehaviour
yield break; yield break;
} }
if (string.IsNullOrEmpty(diffInfo.mapFile))
{
onError?.Invoke($"'{difficulty}' 맵 파일 정보 없음 — Creator에서 곡을 다시 생성해주세요");
yield break;
}
string mapPath = MapPath(song, difficulty); string mapPath = MapPath(song, difficulty);
if (mapPath != null) mapPath = Path.GetFullPath(mapPath);
if (mapPath == null)
{
onError?.Invoke($"'{difficulty}' 맵 경로 계산 실패");
yield break;
}
if (!File.Exists(mapPath)) if (!File.Exists(mapPath))
{ {
bool failed = false; bool failed = false;
+18
View File
@@ -0,0 +1,18 @@
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
/// <summary>
/// Game 씬의 뒤로가기 버튼에 자동으로 추가됩니다.
/// SceneBuilder가 Button에 이 컴포넌트를 달아 씬 이동을 처리합니다.
/// </summary>
[RequireComponent(typeof(Button))]
public class GameBackButton : MonoBehaviour
{
[SerializeField] private string targetScene = "SongSelect";
private void Start()
{
GetComponent<Button>().onClick.AddListener(() => SceneManager.LoadScene(targetScene));
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72739d64e582ca840a355a30eba85d75
+97 -38
View File
@@ -10,15 +10,36 @@ using UnityEngine.Networking;
public class NasPublisher : MonoBehaviour public class NasPublisher : MonoBehaviour
{ {
[Header("NAS 접속 정보")] [Header("NAS 접속 정보")]
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000"; // DSM 포트 (내부망) [SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000";
[SerializeField] private string nasAccount = "admin"; [SerializeField] private string nasAccount = "admin";
[SerializeField] private string nasPassword = ""; // Inspector에서 입력 [SerializeField] private string nasRootPath = "/web/beatsaber";
[SerializeField] private string nasRootPath = "/web/beatsaber"; // NAS 내 절대경로
[Header("정적 서버 URL (songs.json 읽기용)")] [Header("정적 서버 URL (songs.json 읽기용)")]
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me:8180/beatsaber"; [SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";
private string _sid = ""; // DSM 세션 ID private string _sid = "";
private string _synoToken = ""; // DSM 7 CSRF 토큰 (enable_syno_token=yes 로 획득)
private string _password = ""; // StreamingAssets/nas_config.json 에서 로드
private void Awake()
{
LoadConfig();
}
private void LoadConfig()
{
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
if (!File.Exists(path))
{
Debug.LogWarning("[NasPublisher] nas_config.json 없음: " + path);
return;
}
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
_password = cfg?.password ?? "";
}
[Serializable]
private class NasConfig { public string password; }
// ── Public API ─────────────────────────────────────────── // ── Public API ───────────────────────────────────────────
@@ -30,17 +51,17 @@ public class NasPublisher : MonoBehaviour
Action onComplete, Action onComplete,
Action<string> onError) Action<string> onError)
{ {
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
// 1단계: DSM 로그인 // 1단계: DSM 로그인
yield return Login(onError); yield return Login(OnErr);
if (string.IsNullOrEmpty(_sid)) yield break; if (string.IsNullOrEmpty(_sid)) yield break;
onProgress?.Invoke(0.1f); onProgress?.Invoke(0.1f);
// 2단계: 오디오 업로드 // 2단계: 오디오 업로드
yield return UploadFile( yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
audioPath, if (failed) { yield return Logout(); yield break; }
$"{nasRootPath}/music",
$"{song.id}.mp3",
onError);
onProgress?.Invoke(0.4f); onProgress?.Invoke(0.4f);
// 3단계: 각 난이도 맵 JSON 업로드 // 3단계: 각 난이도 맵 JSON 업로드
@@ -53,20 +74,18 @@ public class NasPublisher : MonoBehaviour
string json = BeatSageConverter.ToMapJson(kv.Value); string json = BeatSageConverter.ToMapJson(kv.Value);
byte[] bytes = Encoding.UTF8.GetBytes(json); byte[] bytes = Encoding.UTF8.GetBytes(json);
// 파일명에 맞춰 DifficultyInfo 업데이트
AssignMapFile(song, kv.Key, fileName); AssignMapFile(song, kv.Key, fileName);
yield return UploadBytes( yield return UploadBytes(bytes, fileName, $"{nasRootPath}/maps", OnErr);
bytes, fileName, if (failed) { yield return Logout(); yield break; }
$"{nasRootPath}/maps",
onError);
done++; done++;
onProgress?.Invoke(0.4f + (float)done / total * 0.3f); onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
} }
// 4단계: songs.json 다운로드 → 항목 추가 → 재업로드 // 4단계: songs.json 다운로드 → 항목 추가 → 재업로드
yield return PatchSongsJson(song, onError); yield return PatchSongsJson(song, OnErr);
if (failed) { yield return Logout(); yield break; }
onProgress?.Invoke(0.95f); onProgress?.Invoke(0.95f);
// 5단계: 로그아웃 // 5단계: 로그아웃
@@ -81,11 +100,12 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError) private IEnumerator Login(Action<string> onError)
{ {
Debug.Log($"[NasPublisher] 로그인 시도 — nasBaseUrl: '{nasBaseUrl}'");
string url = $"{nasBaseUrl}/webapi/auth.cgi" + string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=3&method=login" + $"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" + $"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(nasPassword)}" + $"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid"; $"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url); using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest(); yield return req.SendWebRequest();
@@ -96,7 +116,11 @@ public class NasPublisher : MonoBehaviour
yield break; yield break;
} }
_sid = ParseSid(req.downloadHandler.text); Debug.Log($"[NasPublisher] 로그인 응답: {req.downloadHandler.text}");
string resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
Debug.Log($"[NasPublisher] sid={_sid}, synotoken={_synoToken}");
if (string.IsNullOrEmpty(_sid)) if (string.IsNullOrEmpty(_sid))
onError?.Invoke("DSM sid 파싱 실패 — 계정 정보를 확인하세요"); onError?.Invoke("DSM sid 파싱 실패 — 계정 정보를 확인하세요");
} }
@@ -122,26 +146,61 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName, private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError) string nasFolder, Action<string> onError)
{ {
string url = $"{nasBaseUrl}/webapi/entry.cgi"; Debug.Log($"[NasPublisher] 업로드 시도 — path: '{nasFolder}', file: '{fileName}'");
var form = new List<IMultipartFormSection> string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
// PowerShell 테스트와 동일한 방식으로 multipart body 수동 구성
string boundary = System.Guid.NewGuid().ToString("N");
const string CRLF = "\r\n";
using var bodyStream = new MemoryStream();
void WriteText(string text)
{ {
new MultipartFormDataSection("api", "SYNO.FileStation.Upload"), var b = Encoding.UTF8.GetBytes(text);
new MultipartFormDataSection("version", "2"), bodyStream.Write(b, 0, b.Length);
new MultipartFormDataSection("method", "upload"), }
new MultipartFormDataSection("path", nasFolder), void WriteField(string name, string value)
new MultipartFormDataSection("overwrite", "true"), {
new MultipartFormDataSection("_sid", _sid), WriteText($"--{boundary}{CRLF}");
new MultipartFormFileSection("file", bytes, fileName, "application/octet-stream"), WriteText($"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}");
}; WriteText(value);
WriteText(CRLF);
}
WriteField("path", nasFolder);
WriteField("create_parents", "true");
WriteField("overwrite", "true");
WriteText($"--{boundary}{CRLF}");
WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}");
WriteText($"Content-Type: application/octet-stream{CRLF}{CRLF}");
bodyStream.Write(bytes, 0, bytes.Length);
WriteText(CRLF);
WriteText($"--{boundary}--{CRLF}");
byte[] bodyBytes = bodyStream.ToArray();
using var req = new UnityWebRequest(uploadUrl, "POST");
req.uploadHandler = new UploadHandlerRaw(bodyBytes);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
if (!string.IsNullOrEmpty(_synoToken))
req.SetRequestHeader("X-SYNO-TOKEN", _synoToken);
using var req = UnityWebRequest.Post(url, form);
yield return req.SendWebRequest(); yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"업로드 실패({fileName}): {req.error}"); onError?.Invoke($"업로드 실패({fileName}): {req.error}");
else yield break;
Debug.Log($"[NasPublisher] 업로드 완료: {fileName}"); }
Debug.Log($"[NasPublisher] 업로드 응답({fileName}): {req.downloadHandler.text}");
if (req.downloadHandler.text.Contains("\"success\":false"))
onError?.Invoke($"업로드 거부({fileName}): {req.downloadHandler.text}");
} }
// ── songs.json 패치 ─────────────────────────────────────── // ── songs.json 패치 ───────────────────────────────────────
@@ -178,12 +237,12 @@ public class NasPublisher : MonoBehaviour
// ── 유틸 ───────────────────────────────────────────────── // ── 유틸 ─────────────────────────────────────────────────
private static string ParseSid(string json) private static string ParseJsonString(string json, string key)
{ {
const string key = "\"sid\":\""; string search = $"\"{key}\":\"";
int start = json.IndexOf(key, StringComparison.Ordinal); int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null; if (start < 0) return null;
start += key.Length; start += search.Length;
int end = json.IndexOf('"', start); int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null; return end > start ? json.Substring(start, end - start) : null;
} }
+2 -2
View File
@@ -43,17 +43,17 @@ public class SongInfo
[Serializable] [Serializable]
public class DifficultyMap public class DifficultyMap
{ {
public DifficultyInfo easy;
public DifficultyInfo normal; public DifficultyInfo normal;
public DifficultyInfo hard; public DifficultyInfo hard;
public DifficultyInfo expert; public DifficultyInfo expert;
public DifficultyInfo expertplus;
public DifficultyInfo Get(string key) => key switch public DifficultyInfo Get(string key) => key switch
{ {
"easy" => easy,
"normal" => normal, "normal" => normal,
"hard" => hard, "hard" => hard,
"expert" => expert, "expert" => expert,
"expertplus" => expertplus,
_ => null _ => null
}; };
} }
+41
View File
@@ -0,0 +1,41 @@
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// Intro 씬의 XR Origin (Hands)에 추가하세요.
/// DontDestroyOnLoad로 모든 씬에 XR Rig를 유지하고,
/// 씬 전환 시 중복 카메라를 자동으로 비활성화합니다.
/// </summary>
[DisallowMultipleComponent]
public class PersistentXRRig : MonoBehaviour
{
private void Awake()
{
// 이미 다른 PersistentXRRig가 살아있으면 자기 자신을 제거 (싱글턴)
var existing = FindObjectsByType<PersistentXRRig>(FindObjectsSortMode.None);
if (existing.Length > 1)
{
Destroy(gameObject);
return;
}
DontDestroyOnLoad(gameObject);
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
// SceneBuilder가 만든 일반 카메라(Main Camera)를 비활성화
// - XR Rig 자식 카메라는 유지
foreach (var cam in FindObjectsByType<Camera>(FindObjectsSortMode.None))
{
if (!cam.transform.IsChildOf(transform))
cam.gameObject.SetActive(false);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0d94d5e288e7ee34facc8caf424e5c72
+36 -11
View File
@@ -20,14 +20,16 @@ public class SongCreatorManager : MonoBehaviour
[SerializeField] private TMP_InputField bpmInput; [SerializeField] private TMP_InputField bpmInput;
[Header("난이도")] [Header("난이도")]
[SerializeField] private Toggle toggleEasy;
[SerializeField] private Toggle toggleNormal; [SerializeField] private Toggle toggleNormal;
[SerializeField] private Toggle toggleHard; [SerializeField] private Toggle toggleHard;
[SerializeField] private Toggle toggleExpert; [SerializeField] private Toggle toggleExpert;
[SerializeField] private Toggle toggleExpertPlus;
[Header("액션")] [Header("액션")]
[SerializeField] private Button generateButton; [SerializeField] private Button generateButton;
[SerializeField] private Button manualEditorButton; // 작은 버튼 [SerializeField] private Button manualEditorButton; // 작은 버튼
[SerializeField] private Button backButton;
[SerializeField] private string introSceneName = "Intro";
[Header("진행 상태")] [Header("진행 상태")]
[SerializeField] private GameObject progressGroup; [SerializeField] private GameObject progressGroup;
@@ -59,6 +61,7 @@ public class SongCreatorManager : MonoBehaviour
refreshBtn.onClick.AddListener(RefreshAudioList); refreshBtn.onClick.AddListener(RefreshAudioList);
generateButton.onClick.AddListener(OnGenerateClicked); generateButton.onClick.AddListener(OnGenerateClicked);
manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene)); manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene));
backButton?.onClick.AddListener(() => SceneManager.LoadScene(introSceneName));
progressGroup.SetActive(false); progressGroup.SetActive(false);
RefreshAudioList(); RefreshAudioList();
@@ -96,14 +99,18 @@ public class SongCreatorManager : MonoBehaviour
{ SetStatus("BPM을 올바르게 입력해주세요."); return; } { SetStatus("BPM을 올바르게 입력해주세요."); return; }
var diffs = new List<string>(); var diffs = new List<string>();
if (toggleEasy.isOn) diffs.Add("easy");
if (toggleNormal.isOn) diffs.Add("normal"); if (toggleNormal.isOn) diffs.Add("normal");
if (toggleHard.isOn) diffs.Add("hard"); if (toggleHard.isOn) diffs.Add("hard");
if (toggleExpert.isOn) diffs.Add("expert"); if (toggleExpert.isOn) diffs.Add("expert");
if (toggleExpertPlus.isOn) diffs.Add("expertplus");
if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; } if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; }
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpm, diffs)); string audioPath = audioFiles[audioDropdown.value];
Debug.Log($"[SongCreator] 생성 시작 — 파일: {audioPath}, BPM: {bpm}, 난이도: {string.Join(",", diffs)}");
Debug.Log($"[SongCreator] beatSageUploader={beatSageUploader}, nasPublisher={nasPublisher}");
StartCoroutine(GenerateFlow(audioPath, bpm, diffs));
} }
// ── 생성 플로우 ─────────────────────────────────────────── // ── 생성 플로우 ───────────────────────────────────────────
@@ -112,40 +119,58 @@ public class SongCreatorManager : MonoBehaviour
{ {
SetInteractable(false); SetInteractable(false);
progressGroup.SetActive(true); progressGroup.SetActive(true);
Debug.Log("[SongCreator] GenerateFlow 시작");
// 1단계: Beat Sage 전송 → 변환 // 1단계: Beat Sage 전송 → 변환
Dictionary<string, List<NoteData>> maps = null; Dictionary<string, List<NoteData>> maps = null;
bool failed = false; bool failed = false;
Debug.Log("[SongCreator] BeatSage Upload 호출");
yield return beatSageUploader.Upload( yield return beatSageUploader.Upload(
audioPath, diffs, bpm, audioPath, diffs, bpm,
onProgress: p => onProgress: p =>
{ {
progressSlider.value = p * 0.8f; progressSlider.value = p * 0.8f;
SetStatus(beatSageUploader.CurrentStatus); SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
}, },
onComplete: result => maps = result, onComplete: result =>
onError: err => { SetStatus($"Beat Sage 실패: {err}"); failed = true; }); {
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; } if (failed) { SetInteractable(true); yield break; }
// 2단계: NAS 업로드 // 2단계: NAS 업로드
SetStatus("NAS에 업로드 중...");
SongInfo song = BuildSongInfo(audioPath, bpm, maps); SongInfo song = BuildSongInfo(audioPath, bpm, maps);
Debug.Log($"[SongCreator] NAS Publish 호출 — song.id={song.id}");
yield return nasPublisher.Publish( yield return nasPublisher.Publish(
song, audioPath, maps, song, audioPath, maps,
onProgress: p => onProgress: p =>
{ {
progressSlider.value = 0.8f + p * 0.2f; progressSlider.value = 0.8f + p * 0.2f;
SetStatus($"NAS 업로드 중... {(int)((0.8f + p * 0.2f) * 100)}%"); SetStatus($"[4/4] NAS 업로드 중... ({(int)((0.8f + p * 0.2f) * 100)}%)");
}, },
onComplete: () => onComplete: () =>
{ {
progressSlider.value = 1f; progressSlider.value = 1f;
SetStatus($"'{song.title}' 생성 완료!"); SetStatus($"완료! '{song.title}' 생성 성공 (100%)");
Debug.Log($"[SongCreator] NAS 업로드 완료");
}, },
onError: err => { SetStatus($"NAS 업로드 실패: {err}"); failed = true; }); onError: err =>
{
Debug.LogError($"[SongCreator] NAS 오류: {err}");
SetStatus($"NAS 업로드 실패: {err}");
failed = true;
});
SetInteractable(true); SetInteractable(true);
} }
@@ -163,10 +188,10 @@ public class SongCreatorManager : MonoBehaviour
var info = new DifficultyInfo { noteCount = kv.Value.Count }; var info = new DifficultyInfo { noteCount = kv.Value.Count };
switch (kv.Key) switch (kv.Key)
{ {
case "easy": diffMap.easy = info; break;
case "normal": diffMap.normal = info; break; case "normal": diffMap.normal = info; break;
case "hard": diffMap.hard = info; break; case "hard": diffMap.hard = info; break;
case "expert": diffMap.expert = info; break; case "expert": diffMap.expert = info; break;
case "expertplus": diffMap.expertplus = info; break;
} }
} }
+6 -2
View File
@@ -13,15 +13,16 @@ public class SongDetailPanel : MonoBehaviour
[SerializeField] private TMP_Text infoText; [SerializeField] private TMP_Text infoText;
[Header("난이도 버튼")] [Header("난이도 버튼")]
[SerializeField] private Button btnEasy;
[SerializeField] private Button btnNormal; [SerializeField] private Button btnNormal;
[SerializeField] private Button btnHard; [SerializeField] private Button btnHard;
[SerializeField] private Button btnExpert; [SerializeField] private Button btnExpert;
[SerializeField] private Button btnExpertPlus;
[Header("액션 버튼")] [Header("액션 버튼")]
[SerializeField] private Button downloadButton; [SerializeField] private Button downloadButton;
[SerializeField] private Button deleteButton; [SerializeField] private Button deleteButton;
[SerializeField] private Button playButton; [SerializeField] private Button playButton;
[SerializeField] private Button closeButton;
[Header("진행률")] [Header("진행률")]
[SerializeField] private GameObject progressGroup; [SerializeField] private GameObject progressGroup;
@@ -41,10 +42,10 @@ public class SongDetailPanel : MonoBehaviour
private readonly (string key, System.Func<SongDetailPanel, Button> btn)[] diffSlots = private readonly (string key, System.Func<SongDetailPanel, Button> btn)[] diffSlots =
{ {
("easy", p => p.btnEasy),
("normal", p => p.btnNormal), ("normal", p => p.btnNormal),
("hard", p => p.btnHard), ("hard", p => p.btnHard),
("expert", p => p.btnExpert), ("expert", p => p.btnExpert),
("expertplus", p => p.btnExpertPlus),
}; };
// ── Public API ─────────────────────────────────────────── // ── Public API ───────────────────────────────────────────
@@ -101,6 +102,9 @@ public class SongDetailPanel : MonoBehaviour
playButton.onClick.RemoveAllListeners(); playButton.onClick.RemoveAllListeners();
playButton.onClick.AddListener(OnPlayClicked); playButton.onClick.AddListener(OnPlayClicked);
closeButton?.onClick.RemoveAllListeners();
closeButton?.onClick.AddListener(() => gameObject.SetActive(false));
} }
private void SelectDifficulty(string difficulty) private void SelectDifficulty(string difficulty)
+37
View File
@@ -0,0 +1,37 @@
using UnityEngine;
/// <summary>
/// Quest가 아닌 환경(에디터, PC)에서 XR Interaction Simulator를 자동으로 로드합니다.
///
/// 설정 방법:
/// 1. Intro 씬의 XR Origin (또는 임의 GameObject)에 이 스크립트를 추가
/// 2. SimulatorPrefab 필드에 아래 경로의 프리팹을 연결:
/// Assets/Samples/XR Interaction Toolkit/3.3.1/XR Interaction Simulator/XR Interaction Simulator.prefab
///
/// 조작법 (XR Interaction Simulator):
/// - 마우스 우클릭 드래그 : 머리 방향 회전
/// - G 키 누른 채 마우스 이동 : 오른손 컨트롤러 이동
/// - Shift+G : 왼손 컨트롤러
/// - Space : 트리거(UI 클릭)
/// </summary>
public class XRSimulatorLoader : MonoBehaviour
{
[SerializeField] private GameObject simulatorPrefab;
private void Awake()
{
#if !UNITY_ANDROID || UNITY_EDITOR
if (simulatorPrefab != null)
{
Instantiate(simulatorPrefab);
Debug.Log("[XRSimulatorLoader] XR Interaction Simulator 시작");
}
else
{
Debug.LogWarning("[XRSimulatorLoader] simulatorPrefab이 비어 있습니다.\n" +
"Inspector에서 XR Interaction Simulator.prefab을 연결하세요.\n" +
"경로: Assets/Samples/XR Interaction Toolkit/3.3.1/XR Interaction Simulator/XR Interaction Simulator.prefab");
}
#endif
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7e76ce582f56913438ba761e845a91e0