16bc037093
[SceneBuilder] - 사용자 수동 조정 위치 반영 (AudioDropdown, RefreshBtn, FilePickerBtn, UrlInput, UrlDownloadBtn, 난이도 토글 4개, BackButton) - MakeTMP 텍스트 정렬: MidlineLeft → Center - MakePanel/Button/InputField/Dropdown: sprite = null 설정으로 흰색 배경 제거 - 버튼 텍스트 특수문자 → ASCII 대체 (→ > / ▼▶✕ 제거): NanumGothic SDF 미지원 문자 경고 해결 - 난이도 토글 4개 기본값 모두 true로 변경 [SongCreatorManager] - Start()에서 토글 전체 꺼진 경우 자동으로 4개 모두 켜기 (씬 재빌드 없이 즉시 적용) [Spawner] - Awake(): playOnAwake = false, clip = null 설정 — Inspector 하드코딩 클립 자동 재생 차단 - InitGame(): AudioSource/GameSession null 체크, 파일 존재 확인, 경로 로그 추가 - audioSource.clip == null 후 yield break로 로드 실패 시 재생 방지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
14 KiB
C#
356 lines
14 KiB
C#
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;
|
|
}
|
|
}
|