비트 찍기 완료 및 클로드를 통한 api작업
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17a8872686c9b054f9bbdac194e41fb6
|
||||
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
private static readonly Dictionary<string, string> DiffNames = new()
|
||||
{
|
||||
{ "easy", "Easy" },
|
||||
{ "normal", "Normal" },
|
||||
{ "hard", "Hard" },
|
||||
{ "expert", "Expert" },
|
||||
};
|
||||
|
||||
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
|
||||
private static readonly Dictionary<string, string> DatFileNames = new()
|
||||
{
|
||||
{ "easy", "Easy.dat" },
|
||||
{ "normal", "Normal.dat" },
|
||||
{ "hard", "Hard.dat" },
|
||||
{ "expert", "Expert.dat" },
|
||||
};
|
||||
|
||||
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("Beat Sage에 음원 전송 중...");
|
||||
string levelId = null;
|
||||
|
||||
yield return CreateLevel(audioPath, difficulties,
|
||||
id => levelId = id,
|
||||
onError);
|
||||
|
||||
if (levelId == null) yield break;
|
||||
onProgress?.Invoke(0.15f);
|
||||
|
||||
// 2단계: 생성 완료 폴링
|
||||
SetStatus("Beat Sage 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 = status == "generated";
|
||||
error = status == "error";
|
||||
},
|
||||
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($"Beat Sage AI 맵 생성 중... ({(int)elapsed}s)");
|
||||
}
|
||||
|
||||
if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; }
|
||||
|
||||
// 3단계: .zip 다운로드
|
||||
SetStatus("결과 다운로드 중...");
|
||||
byte[] zipBytes = null;
|
||||
|
||||
yield return DownloadZip(levelId,
|
||||
bytes => zipBytes = bytes,
|
||||
onError);
|
||||
|
||||
if (zipBytes == null) yield break;
|
||||
onProgress?.Invoke(0.9f);
|
||||
|
||||
// 4단계: .zip 해제 + BeatSageConverter 변환
|
||||
SetStatus("맵 데이터 변환 중...");
|
||||
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("변환 완료");
|
||||
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","hard"] → "Easy,Hard"
|
||||
var diffStr = string.Join(",", difficulties.ConvertAll(d =>
|
||||
DiffNames.TryGetValue(d, out var n) ? n : d));
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36b8f9eab08910f4285303fdcee4715e
|
||||
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6fab983d0eede0b488541a9404258c2b
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6e8b48d2a534ce4cbbd814c811b8706
|
||||
@@ -0,0 +1,147 @@
|
||||
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:8180/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) return null;
|
||||
return Path.Combine(SongDir(song.id), Path.GetFileName(info.mapFile));
|
||||
}
|
||||
|
||||
// ── 내부 구현 ─────────────────────────────────────────────
|
||||
|
||||
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
||||
Action<float> onProgress, Action onComplete, Action<string> onError)
|
||||
{
|
||||
Directory.CreateDirectory(SongDir(song.id));
|
||||
|
||||
// 1단계: 오디오 (70%)
|
||||
string audioPath = AudioPath(song.id);
|
||||
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;
|
||||
}
|
||||
|
||||
string mapPath = MapPath(song, difficulty);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a5af08e7a62bef4fa5126eea7be83d9
|
||||
@@ -0,0 +1,6 @@
|
||||
// 곡 선택 UI → Game 씬으로 선택 정보를 전달하는 정적 컨테이너
|
||||
public static class GameSession
|
||||
{
|
||||
public static SongInfo SelectedSong;
|
||||
public static string SelectedDifficulty;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b7e0d47d523f40448846f42acdbb11a
|
||||
@@ -0,0 +1,21 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af4b90127ce36634998ca553050a39e0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18614e0561d48a748a839a354f0ff42a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,215 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dde370502e0759409a88f7bbe9f6f00
|
||||
@@ -0,0 +1,81 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9e23740cc2c239489d946a0270e4ffd
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37c3efbe079d24e468e87c1f35337cd9
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,196 @@
|
||||
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"; // DSM 포트 (내부망)
|
||||
[SerializeField] private string nasAccount = "admin";
|
||||
[SerializeField] private string nasPassword = ""; // Inspector에서 입력
|
||||
[SerializeField] private string nasRootPath = "/web/beatsaber"; // NAS 내 절대경로
|
||||
|
||||
[Header("정적 서버 URL (songs.json 읽기용)")]
|
||||
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me:8180/beatsaber";
|
||||
|
||||
private string _sid = ""; // DSM 세션 ID
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
public IEnumerator Publish(
|
||||
SongInfo song,
|
||||
string audioPath,
|
||||
Dictionary<string, List<NoteData>> maps,
|
||||
Action<float> onProgress,
|
||||
Action onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
// 1단계: DSM 로그인
|
||||
yield return Login(onError);
|
||||
if (string.IsNullOrEmpty(_sid)) yield break;
|
||||
onProgress?.Invoke(0.1f);
|
||||
|
||||
// 2단계: 오디오 업로드
|
||||
yield return UploadFile(
|
||||
audioPath,
|
||||
$"{nasRootPath}/music",
|
||||
$"{song.id}.mp3",
|
||||
onError);
|
||||
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);
|
||||
|
||||
// 파일명에 맞춰 DifficultyInfo 업데이트
|
||||
AssignMapFile(song, kv.Key, fileName);
|
||||
|
||||
yield return UploadBytes(
|
||||
bytes, fileName,
|
||||
$"{nasRootPath}/maps",
|
||||
onError);
|
||||
|
||||
done++;
|
||||
onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
|
||||
}
|
||||
|
||||
// 4단계: songs.json 다운로드 → 항목 추가 → 재업로드
|
||||
yield return PatchSongsJson(song, onError);
|
||||
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)
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=3&method=login" +
|
||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||
$"&passwd={UnityWebRequest.EscapeURL(nasPassword)}" +
|
||||
$"&session=FileStation&format=sid";
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"DSM 로그인 실패: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
_sid = ParseSid(req.downloadHandler.text);
|
||||
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)
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/entry.cgi";
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
{
|
||||
new MultipartFormDataSection("api", "SYNO.FileStation.Upload"),
|
||||
new MultipartFormDataSection("version", "2"),
|
||||
new MultipartFormDataSection("method", "upload"),
|
||||
new MultipartFormDataSection("path", nasFolder),
|
||||
new MultipartFormDataSection("overwrite", "true"),
|
||||
new MultipartFormDataSection("_sid", _sid),
|
||||
new MultipartFormFileSection("file", bytes, fileName, "application/octet-stream"),
|
||||
};
|
||||
|
||||
using var req = UnityWebRequest.Post(url, form);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
onError?.Invoke($"업로드 실패({fileName}): {req.error}");
|
||||
else
|
||||
Debug.Log($"[NasPublisher] 업로드 완료: {fileName}");
|
||||
}
|
||||
|
||||
// ── 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 ParseSid(string json)
|
||||
{
|
||||
const string key = "\"sid\":\"";
|
||||
int start = json.IndexOf(key, StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
start += key.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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aed167b4baa5c114a8c3cb0471ec8fa0
|
||||
@@ -0,0 +1,67 @@
|
||||
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 easy;
|
||||
public DifficultyInfo normal;
|
||||
public DifficultyInfo hard;
|
||||
public DifficultyInfo expert;
|
||||
|
||||
public DifficultyInfo Get(string key) => key switch
|
||||
{
|
||||
"easy" => easy,
|
||||
"normal" => normal,
|
||||
"hard" => hard,
|
||||
"expert" => expert,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DifficultyInfo
|
||||
{
|
||||
public string mapFile;
|
||||
public long mapSize;
|
||||
public int noteCount;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f3255da09b9a2749b9a0369b640188c
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.ComponentModel.Design.Serialization;
|
||||
using UnityEngine;
|
||||
using EzySlice;
|
||||
|
||||
public class Saber : MonoBehaviour
|
||||
{
|
||||
|
||||
//손잡이랑 검끝 사이를 레이 쏠 예정(검날)
|
||||
[Header("슬라이스용 직렬화")]
|
||||
public Transform startSlicePoint;
|
||||
public Transform endSlicePoint;
|
||||
public VelocityEstimator velocityEstimator; //속도측정기(검 끝에 달림)
|
||||
public LayerMask sliceableLayer; //하는김에 얘도 그냥 직렬화
|
||||
|
||||
[Header("이펙트 세팅")]
|
||||
public Material cuttingMaterial; //절단면
|
||||
public float cutForce = 100f; //잘린 조각 튕기기
|
||||
|
||||
[Header("슬라이스 조건 설정")]
|
||||
[Range(60f, 160f)]
|
||||
public float sliceAngle = 100f; // 필요 각도 (직렬화해서 인스펙터에서 조절 가능하게)
|
||||
|
||||
[Tooltip("이 속도보다 빨라야만 슬라이싱이 작동합니다.")]
|
||||
public float swingSpeedThreshold = 2.0f; // 필요 최소 속도 (이 값을 조절해서 '갖다 대기'를 방지)
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
//손잡이와 검 끝 사이에 선을 그어 체크
|
||||
bool hasHit = Physics.Linecast(
|
||||
startSlicePoint.position,
|
||||
endSlicePoint.position,
|
||||
out RaycastHit hit,
|
||||
sliceableLayer);
|
||||
|
||||
//체크됐으면
|
||||
if (hasHit)
|
||||
{
|
||||
//hit 된 오브젝트를 타겟으로 자르기 메서드 실행
|
||||
GameObject target = hit.transform.gameObject;
|
||||
|
||||
Vector3 swingVelocity = velocityEstimator.Velocity;
|
||||
|
||||
float currentSwingSpeed = swingVelocity.magnitude;
|
||||
|
||||
if (currentSwingSpeed > swingSpeedThreshold) // 1단계: 속도 체크
|
||||
{
|
||||
// 5. 각도 체크
|
||||
// swingVelocity.normalized를 쓰면 크기를 무시하고 방향만 비교할 수 있어 더 정확합니다.
|
||||
float angle = Vector3.Angle(swingVelocity.normalized, target.transform.up);
|
||||
|
||||
if (angle > sliceAngle) // 2단계: 각도 체크
|
||||
{
|
||||
Slice(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//자르는 메서드
|
||||
public void Slice(GameObject target)
|
||||
{
|
||||
//1. 자를 평면 방향 구하기
|
||||
//1-1. 검 방향 벡터
|
||||
Vector3 saberDirection = endSlicePoint.position - startSlicePoint.position;
|
||||
|
||||
//1-2. 검 속도벡터(velocityEstimator.Velocity)
|
||||
Vector3 velocity = velocityEstimator.Velocity;
|
||||
|
||||
//1-3. 두 벡터의 수직인 벡터를 구해서 정규화 = 자르는 면의 방향
|
||||
Vector3 planeNormal = Vector3.Cross(saberDirection, velocity).normalized;
|
||||
|
||||
//2. 자르기
|
||||
SlicedHull hull = target.Slice(endSlicePoint.position, planeNormal, cuttingMaterial);
|
||||
if (hull == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//3. 위아래조각 생성(여기부턴 동일)
|
||||
GameObject upperHull = hull.CreateUpperHull(target, cuttingMaterial);
|
||||
GameObject lowerHull = hull.CreateLowerHull(target, cuttingMaterial);
|
||||
|
||||
//4. 조각들에 SetupHull 물리부여
|
||||
SetupHull(upperHull);
|
||||
SetupHull(lowerHull);
|
||||
|
||||
|
||||
//5. 원본은 삭제
|
||||
Destroy(target);
|
||||
}
|
||||
|
||||
|
||||
//물리 부여
|
||||
//얜 거의 안 건드리고 레이어 바꾸는 거만 추가
|
||||
void SetupHull(GameObject hull)
|
||||
{
|
||||
Rigidbody rb = hull.AddComponent<Rigidbody>();
|
||||
MeshCollider collider = hull.AddComponent<MeshCollider>();
|
||||
collider.convex = true;//물리충돌용
|
||||
|
||||
//잘린 면 방향으로 힘을 가해 튕기게
|
||||
rb.AddExplosionForce(cutForce, hull.transform.position, 1f);
|
||||
|
||||
//레이어 바꿔서 계속 잘리는 거 방지(소리엉킴)
|
||||
hull.layer = LayerMask.NameToLayer("Default");
|
||||
|
||||
Destroy(hull, 3f); // 3초 뒤 삭제
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1198f924174495469408455924b5610
|
||||
@@ -0,0 +1,25 @@
|
||||
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}";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9eaee5b70034c244eb6060b8cdff58eb
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
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 TMP_InputField titleInput;
|
||||
[SerializeField] private TMP_InputField artistInput;
|
||||
[SerializeField] private TMP_InputField bpmInput;
|
||||
|
||||
[Header("난이도")]
|
||||
[SerializeField] private Toggle toggleEasy;
|
||||
[SerializeField] private Toggle toggleNormal;
|
||||
[SerializeField] private Toggle toggleHard;
|
||||
[SerializeField] private Toggle toggleExpert;
|
||||
|
||||
[Header("액션")]
|
||||
[SerializeField] private Button generateButton;
|
||||
[SerializeField] private Button manualEditorButton; // 작은 버튼
|
||||
|
||||
[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();
|
||||
|
||||
// ── Unity ────────────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Directory.CreateDirectory(InputPath);
|
||||
|
||||
if (inputPathHint != null)
|
||||
inputPathHint.text = $"음원 경로: {InputPath}";
|
||||
|
||||
refreshBtn.onClick.AddListener(RefreshAudioList);
|
||||
generateButton.onClick.AddListener(OnGenerateClicked);
|
||||
manualEditorButton.onClick.AddListener(() => SceneManager.LoadScene(mapEditorScene));
|
||||
|
||||
progressGroup.SetActive(false);
|
||||
RefreshAudioList();
|
||||
}
|
||||
|
||||
// ── 음원 목록 갱신 ───────────────────────────────────────
|
||||
|
||||
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 (toggleEasy.isOn) diffs.Add("easy");
|
||||
if (toggleNormal.isOn) diffs.Add("normal");
|
||||
if (toggleHard.isOn) diffs.Add("hard");
|
||||
if (toggleExpert.isOn) diffs.Add("expert");
|
||||
|
||||
if (diffs.Count == 0) { SetStatus("난이도를 하나 이상 선택해주세요."); return; }
|
||||
|
||||
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpm, diffs));
|
||||
}
|
||||
|
||||
// ── 생성 플로우 ───────────────────────────────────────────
|
||||
|
||||
private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
|
||||
{
|
||||
SetInteractable(false);
|
||||
progressGroup.SetActive(true);
|
||||
|
||||
// 1단계: Beat Sage 전송 → 변환
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
yield return beatSageUploader.Upload(
|
||||
audioPath, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
progressSlider.value = p * 0.8f;
|
||||
SetStatus(beatSageUploader.CurrentStatus);
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Beat Sage 실패: {err}"); failed = true; });
|
||||
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
// 2단계: NAS 업로드
|
||||
SetStatus("NAS에 업로드 중...");
|
||||
SongInfo song = BuildSongInfo(audioPath, bpm, maps);
|
||||
|
||||
yield return nasPublisher.Publish(
|
||||
song, audioPath, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"NAS 업로드 중... {(int)((0.8f + p * 0.2f) * 100)}%");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
progressSlider.value = 1f;
|
||||
SetStatus($"'{song.title}' 생성 완료!");
|
||||
},
|
||||
onError: 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 "easy": diffMap.easy = info; break;
|
||||
case "normal": diffMap.normal = info; break;
|
||||
case "hard": diffMap.hard = info; break;
|
||||
case "expert": diffMap.expert = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5c960fcd6008a849ac5709213911f5d
|
||||
@@ -0,0 +1,218 @@
|
||||
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 btnEasy;
|
||||
[SerializeField] private Button btnNormal;
|
||||
[SerializeField] private Button btnHard;
|
||||
[SerializeField] private Button btnExpert;
|
||||
|
||||
[Header("액션 버튼")]
|
||||
[SerializeField] private Button downloadButton;
|
||||
[SerializeField] private Button deleteButton;
|
||||
[SerializeField] private Button playButton;
|
||||
|
||||
[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 =
|
||||
{
|
||||
("easy", p => p.btnEasy),
|
||||
("normal", p => p.btnNormal),
|
||||
("hard", p => p.btnHard),
|
||||
("expert", p => p.btnExpert),
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7486780aac3b88249b9d78712ed72637
|
||||
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18ce3ae50fe8ac74498664297350d5f0
|
||||
@@ -0,0 +1,136 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df07151c232d3dd4fa61664629d53203
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
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";
|
||||
|
||||
private List<NoteData> mapNotes = new List<NoteData>();
|
||||
private int nextNoteIndex = 0;
|
||||
private float travelTime;
|
||||
private bool isReady = false;
|
||||
|
||||
// ── Unity ────────────────────────────────────────────────
|
||||
|
||||
void Start()
|
||||
{
|
||||
travelTime = distanceToHit / noteSpeed;
|
||||
|
||||
if (GameSession.SelectedSong == null)
|
||||
{
|
||||
Debug.LogWarning("[Spawner] 선택된 곡 없음 → 곡 선택 화면으로 이동");
|
||||
SceneManager.LoadScene(songSelectSceneName);
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(InitGame());
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
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;
|
||||
|
||||
// 맵 JSON 로드
|
||||
string mapPath = GetMapPath(song, difficulty);
|
||||
if (!File.Exists(mapPath))
|
||||
{
|
||||
Debug.LogError($"[Spawner] 맵 파일 없음: {mapPath}");
|
||||
yield break;
|
||||
}
|
||||
LoadMapJson(mapPath);
|
||||
|
||||
// 오디오 로드 (로컬 파일 → AudioClip)
|
||||
string audioPath = GetAudioPath(song.id);
|
||||
yield return LoadAudioClip(audioPath);
|
||||
|
||||
// LRU 갱신
|
||||
SongLibrary.Instance?.TouchSong(song.id);
|
||||
|
||||
isReady = true;
|
||||
audioSource.Play();
|
||||
Debug.Log($"[Spawner] 시작: {song.title} ({difficulty})");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b0370d061b6ce824685b1470ed1a9ed2
|
||||
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
|
||||
//검 끝에 붙일 스크립트
|
||||
public class VelocityEstimator : MonoBehaviour
|
||||
{
|
||||
//속도값 전달용
|
||||
public Vector3 Velocity { get; private set; }
|
||||
|
||||
private Vector3 previousPosition;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
previousPosition = transform.position;
|
||||
}
|
||||
|
||||
//물리 연산 주기에 맞춰 속도 계산
|
||||
private void FixedUpdate()
|
||||
{
|
||||
//(현재 위치 - 이전 위치) / 시간 = 속도
|
||||
Velocity = (transform.position - previousPosition) / Time.fixedDeltaTime;
|
||||
previousPosition = transform.position;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c611db3f29508604b8d70ad30f9b4f3c
|
||||
Reference in New Issue
Block a user