58838f0acb
학습용 HTML 2개 추가 — 전체 15개 스크립트 줄 단위 주석본(annotated_code.html)과 설계 분석/퀴즈 문서(code_review.html). LiberationSans SDF Fallback 에셋 교체. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2138 lines
176 KiB
HTML
2138 lines
176 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>VR Beat Saber — 주석 코드 전집</title>
|
||
<style>
|
||
:root{--bg:#0d1117;--sf:#161b22;--sf2:#1c2230;--bd:#30363d;--ac:#58a6ff;--gn:#3fb950;--ye:#d29922;--rd:#f85149;--pu:#bc8cff;--or:#ffa657;--tx:#e6edf3;--mu:#8b949e}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--tx);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;display:flex;height:100vh;overflow:hidden}
|
||
/* 사이드바 */
|
||
#sb{width:220px;min-width:220px;background:var(--sf);border-right:1px solid var(--bd);overflow-y:auto;padding:12px 0;display:flex;flex-direction:column;gap:2px}
|
||
#sb h2{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--bd);padding:10px 14px 4px}
|
||
.tab-btn{display:block;padding:6px 14px;color:var(--mu);text-align:left;background:none;border:none;border-left:2px solid transparent;cursor:pointer;font-size:12px;width:100%;transition:all .12s}
|
||
.tab-btn:hover{color:var(--tx);background:rgba(255,255,255,.04)}
|
||
.tab-btn.active{color:var(--ac);border-left-color:var(--ac);background:rgba(88,166,255,.08)}
|
||
/* 메인 */
|
||
#main{flex:1;overflow-y:auto;padding:32px 40px}
|
||
/* 파일 패널 */
|
||
.panel{display:none}.panel.active{display:block}
|
||
.file-header{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--bd)}
|
||
.file-header h1{font-size:22px;font-weight:700}
|
||
.file-header p{color:var(--mu);margin-top:6px;line-height:1.6}
|
||
/* 코드 */
|
||
.cw{background:#0a0e14;border:1px solid var(--bd);border-radius:8px;overflow:hidden;margin:14px 0}
|
||
.ch{background:var(--sf);padding:7px 14px;border-bottom:1px solid var(--bd);font-size:11px;color:var(--mu);display:flex;justify-content:space-between}
|
||
.ch span:first-child{color:var(--tx);font-weight:600}
|
||
pre{padding:14px 18px;overflow-x:auto;font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:12px;line-height:1.75;tab-size:4}
|
||
/* 토큰 */
|
||
.kw{color:#ff7b72}.ty{color:#ffa657}.fn{color:#d2a8ff}.st{color:#a5d6ff}.nm{color:#79c0ff}.cm{color:#6e7681;font-style:italic}.op{color:#ff7b72}.va{color:#e6edf3}
|
||
/* 어노테이션 */
|
||
.ann{color:#e3b341;font-style:italic;font-weight:500} /* 노란색 = 설명 */
|
||
.why{color:#56d364;font-style:italic} /* 초록 = 이유/배경 */
|
||
.bug{color:#f85149;font-style:italic} /* 빨간 = 주의/버그 */
|
||
/* 콜아웃 */
|
||
.box{border-left:3px solid;padding:10px 14px;border-radius:0 6px 6px 0;margin:10px 0;font-size:12.5px}
|
||
.box-b{border-color:var(--ac);background:rgba(88,166,255,.06)}
|
||
.box-g{border-color:var(--gn);background:rgba(63,185,80,.06)}
|
||
.box-y{border-color:var(--ye);background:rgba(210,153,34,.06)}
|
||
.box-r{border-color:var(--rd);background:rgba(248,81,73,.06)}
|
||
.box .lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}
|
||
.box-b .lbl{color:var(--ac)}.box-g .lbl{color:var(--gn)}.box-y .lbl{color:var(--ye)}.box-r .lbl{color:var(--rd)}
|
||
.box p{color:var(--tx);margin:0;line-height:1.6}
|
||
h3{font-size:14px;font-weight:700;color:var(--ac);margin:24px 0 8px}
|
||
h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;letter-spacing:.06em;margin:18px 0 6px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav id="sb">
|
||
<h2>데이터 모델</h2>
|
||
<button class="tab-btn active" onclick="show('notedata')">NoteData.cs</button>
|
||
<button class="tab-btn" onclick="show('gamesession')">GameSession.cs</button>
|
||
<h2>변환 / API</h2>
|
||
<button class="tab-btn" onclick="show('converter')">BeatSageConverter.cs</button>
|
||
<button class="tab-btn" onclick="show('uploader')">BeatSageUploader.cs</button>
|
||
<h2>네트워크 / 파일</h2>
|
||
<button class="tab-btn" onclick="show('naspub')">NasPublisher.cs</button>
|
||
<button class="tab-btn" onclick="show('dlmgr')">DownloadManager.cs</button>
|
||
<h2>게임 씬</h2>
|
||
<button class="tab-btn" onclick="show('songctrl')">SongController.cs</button>
|
||
<button class="tab-btn" onclick="show('audiomgr')">AudioManager.cs</button>
|
||
<h2>UI</h2>
|
||
<button class="tab-btn" onclick="show('creator')">SongCreatorManager.cs</button>
|
||
<button class="tab-btn" onclick="show('detail')">SongDetailPanel.cs</button>
|
||
<button class="tab-btn" onclick="show('selectmgr')">SongSelectManager.cs</button>
|
||
<button class="tab-btn" onclick="show('marquee')">MarqueeText.cs</button>
|
||
<h2>유틸</h2>
|
||
<button class="tab-btn" onclick="show('songlibrary')">SongLibrary.cs</button>
|
||
<button class="tab-btn" onclick="show('desktop')">DesktopUIMode.cs</button>
|
||
<button class="tab-btn" onclick="show('xrloader')">XRSimulatorLoader.cs</button>
|
||
</nav>
|
||
|
||
<div id="main">
|
||
|
||
<!-- ══════════════════════ NoteData.cs ══════════════════════ -->
|
||
<div id="p-notedata" class="panel active">
|
||
<div class="file-header">
|
||
<h1>NoteData.cs</h1>
|
||
<p>프로젝트 전체에서 쓰는 <strong>데이터 구조(DTO)</strong>를 모아놓은 파일. 로직은 없고 순수하게 데이터만 담는다.</p>
|
||
</div>
|
||
|
||
<div class="box box-b"><div class="lbl">이 파일의 역할</div><p>NAS의 JSON ↔ C# 객체 ↔ Beat Saber .dat 사이의 데이터 형식을 정의한다. Unity의 <code>JsonUtility</code>가 읽고 쓸 수 있도록 <code>[Serializable]</code>이 붙는다.</p></div>
|
||
|
||
<div class="cw"><div class="ch"><span>NoteData.cs</span></div><pre>
|
||
<span class="kw">using</span> System;
|
||
<span class="kw">using</span> System.Collections.Generic;
|
||
<span class="kw">using</span> UnityEngine;
|
||
|
||
<span class="ann">// [Serializable] = JsonUtility가 이 클래스를 JSON으로 변환/역변환할 수 있게 허용하는 속성</span>
|
||
<span class="ann">// 붙이지 않으면 JsonUtility.ToJson()이 빈 {} 를 반환한다</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">NoteData</span>
|
||
{
|
||
<span class="ann">// Beat Sage가 반환하는 .dat 파일의 _time(비트단위)을 BPM으로 나눠 초 단위로 변환한 값</span>
|
||
<span class="ann">// → 이 time은 "음악 시작 후 몇 초에 이 노트를 쳐야 하나"</span>
|
||
<span class="kw">public</span> <span class="ty">float</span> time;
|
||
|
||
<span class="ann">// 가로 열 위치: 0(왼쪽) ~ 3(오른쪽). Beat Saber 공식 4열 그리드</span>
|
||
<span class="kw">public</span> <span class="ty">int</span> position;
|
||
|
||
<span class="ann">// 세로 행 위치: 0(아래) ~ 2(위). Beat Saber 공식 3행 그리드</span>
|
||
<span class="kw">public</span> <span class="ty">int</span> lineLayer;
|
||
|
||
<span class="ann">// 0 = 빨간 블록(왼손 사이버), 1 = 파란 블록(오른손 사이버)</span>
|
||
<span class="kw">public</span> <span class="ty">int</span> colorType;
|
||
|
||
<span class="ann">// 어느 방향으로 칼날을 휘둘러야 하는지</span>
|
||
<span class="ann">// 0=위 1=아래 2=왼 3=오른 4=왼위 5=오른위 6=왼아래 7=오른아래 8=아무방향(점)</span>
|
||
<span class="kw">public</span> <span class="ty">int</span> cutDirection;
|
||
}
|
||
|
||
<span class="ann">// 맵 JSON 파일의 최상위 구조 — { "target": [ ...NoteData 배열... ] }</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">MapData</span>
|
||
{
|
||
<span class="ann">// "target"이라는 이름으로 저장된다. JsonUtility는 필드명 = JSON 키</span>
|
||
<span class="kw">public</span> <span class="ty">List</span><<span class="ty">NoteData</span>> target;
|
||
}
|
||
|
||
<span class="ann">// NAS의 songs.json 전체 구조 — { "version": "1.0", "songs": [...] }</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">SongsList</span>
|
||
{
|
||
<span class="kw">public</span> <span class="ty">string</span> version;
|
||
<span class="kw">public</span> <span class="ty">List</span><<span class="ty">SongInfo</span>> songs;
|
||
}
|
||
|
||
<span class="ann">// 곡 하나의 메타데이터. songs.json의 각 원소이자 GameSession으로 씬 간 전달되는 핵심 객체</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">SongInfo</span>
|
||
{
|
||
<span class="ann">// id = 곡의 유일 식별자. 파일 경로/캐시 폴더명에도 사용된다</span>
|
||
<span class="ann">// 예: "my_song" → 캐시 폴더: temporaryCachePath/beatsaber/my_song/</span>
|
||
<span class="kw">public</span> <span class="ty">string</span> id;
|
||
<span class="kw">public</span> <span class="ty">string</span> title;
|
||
<span class="kw">public</span> <span class="ty">string</span> artist;
|
||
<span class="kw">public</span> <span class="ty">float</span> bpm;
|
||
<span class="kw">public</span> <span class="ty">int</span> duration; <span class="ann">// 초 단위 길이 (현재는 사용 안 함)</span>
|
||
<span class="ann">// audioFile = NAS 상의 상대 경로. 예: "music/my_song.mp3"</span>
|
||
<span class="ann">// DownloadManager가 baseUrl + audioFile 로 전체 URL을 조합한다</span>
|
||
<span class="kw">public</span> <span class="ty">string</span> audioFile;
|
||
<span class="kw">public</span> <span class="ty">long</span> audioSize;
|
||
<span class="kw">public</span> <span class="ty">string</span> coverImage;
|
||
<span class="kw">public</span> <span class="ty">DifficultyMap</span> difficulties; <span class="ann">// 난이도별 맵 파일 정보</span>
|
||
<span class="kw">public</span> <span class="ty">string</span> addedAt; <span class="ann">// "2026-05-22" 형식</span>
|
||
}
|
||
|
||
<span class="ann">// 4가지 난이도를 각각 필드로 가지는 구조</span>
|
||
<span class="ann">// Dictionary를 쓰면 더 유연하지만, JsonUtility는 Dictionary를 직렬화 못한다 → 필드로 하드코딩</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">DifficultyMap</span>
|
||
{
|
||
<span class="kw">public</span> <span class="ty">DifficultyInfo</span> normal;
|
||
<span class="kw">public</span> <span class="ty">DifficultyInfo</span> hard;
|
||
<span class="kw">public</span> <span class="ty">DifficultyInfo</span> expert;
|
||
<span class="kw">public</span> <span class="ty">DifficultyInfo</span> expertplus;
|
||
|
||
<span class="ann">// 문자열 key로 해당 난이도 정보를 꺼내는 헬퍼 메서드</span>
|
||
<span class="ann">// C# 8의 switch expression: 중괄호 없이 => 로 값을 반환하는 switch</span>
|
||
<span class="ann">// _ = default case. 알 수 없는 난이도면 null 반환</span>
|
||
<span class="kw">public</span> <span class="ty">DifficultyInfo</span> <span class="fn">Get</span>(<span class="ty">string</span> key) <span class="op">=></span> key <span class="kw">switch</span>
|
||
{
|
||
<span class="st">"normal"</span> <span class="op">=></span> normal,
|
||
<span class="st">"hard"</span> <span class="op">=></span> hard,
|
||
<span class="st">"expert"</span> <span class="op">=></span> expert,
|
||
<span class="st">"expertplus"</span> <span class="op">=></span> expertplus,
|
||
_ <span class="op">=></span> <span class="kw">null</span>
|
||
};
|
||
}
|
||
|
||
<span class="ann">// 난이도 하나의 파일 정보. mapFile은 NAS 상대경로. 예: "maps/Map_my_song_hard.json"</span>
|
||
[<span class="ty">Serializable</span>]
|
||
<span class="kw">public class</span> <span class="ty">DifficultyInfo</span>
|
||
{
|
||
<span class="ann">// NasPublisher.AssignMapFile()이 업로드 후 이 값을 채운다</span>
|
||
<span class="kw">public</span> <span class="ty">string</span> mapFile;
|
||
<span class="kw">public</span> <span class="ty">long</span> mapSize;
|
||
<span class="kw">public</span> <span class="ty">int</span> noteCount; <span class="ann">// 해당 난이도의 총 노트 수</span>
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ GameSession.cs ══════════════════════ -->
|
||
<div id="p-gamesession" class="panel">
|
||
<div class="file-header">
|
||
<h1>GameSession.cs</h1>
|
||
<p>SongSelect → Game 씬으로 "어떤 곡을 어떤 난이도로 플레이할지" 정보를 전달하는 전역 컨테이너.</p>
|
||
</div>
|
||
<div class="box box-y"><div class="lbl">씬 전환과 데이터 전달 문제</div><p>Unity는 씬이 바뀌면 모든 GameObject가 파괴된다. 씬 간 데이터를 넘기려면 파괴되지 않는 곳에 저장해야 한다. static 클래스는 앱 프로세스가 살아있는 동안 메모리에 남기 때문에 가장 단순한 해법이다.</p></div>
|
||
<div class="cw"><div class="ch"><span>GameSession.cs</span></div><pre>
|
||
<span class="ann">// static class = 인스턴스를 만들 수 없다 (new GameSession() 불가)</span>
|
||
<span class="ann">// 모든 멤버가 자동으로 static → 어디서든 GameSession.SelectedSong 으로 접근</span>
|
||
<span class="ann">// MonoBehaviour가 아니므로 씬 전환으로 파괴되지 않는다</span>
|
||
<span class="kw">public static class</span> <span class="ty">GameSession</span>
|
||
{
|
||
<span class="ann">// SongDetailPanel.OnPlayClicked()에서 여기에 저장하고</span>
|
||
<span class="ann">// SongController.LoadAndPlay()에서 여기서 읽는다</span>
|
||
<span class="kw">public static</span> <span class="ty">SongInfo</span> SelectedSong;
|
||
<span class="kw">public static</span> <span class="ty">string</span> SelectedDifficulty; <span class="ann">// "normal" / "hard" / "expert" / "expertplus"</span>
|
||
}
|
||
<span class="ann">// ⚠️ 한계: Game 씬을 에디터에서 직접 Play하면 이 값이 null → SongController가 오류</span>
|
||
<span class="ann">// 항상 Menu/SongSelect 씬부터 시작해야 한다</span>
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ BeatSageConverter.cs ══════════════════════ -->
|
||
<div id="p-converter" class="panel">
|
||
<div class="file-header">
|
||
<h1>BeatSageConverter.cs</h1>
|
||
<p>Beat Sage AI가 반환하는 <code>.dat</code> JSON 포맷을 우리 내부 <code>NoteData</code>로 변환하는 순수 유틸리티.</p>
|
||
</div>
|
||
<div class="box box-b"><div class="lbl">Beat Saber .dat 포맷이란?</div><p>Beat Saber 공식 커스텀 맵 포맷. <code>_time</code>은 비트(beat) 단위이고 BPM으로 나눠야 실제 초가 된다. <code>_type</code>은 0=빨강, 1=파랑, 3=폭탄(스킵). 우리 게임은 NoteData 포맷을 쓰므로 변환이 필요하다.</p></div>
|
||
<div class="cw"><div class="ch"><span>BeatSageConverter.cs</span></div><pre>
|
||
<span class="ann">// [Serializable] + 필드명 앞에 _ = Beat Saber .dat JSON 키와 정확히 일치해야 JsonUtility가 파싱</span>
|
||
[<span class="ty">System.Serializable</span>] <span class="kw">public class</span> <span class="ty">SongMetadata</span>
|
||
{
|
||
<span class="kw">public string</span> title; <span class="kw">public string</span> artist; <span class="kw">public float</span> bpm;
|
||
}
|
||
|
||
<span class="ann">// info.dat 파일 구조 — Beat Sage가 ZIP에 포함하는 메타데이터 파일</span>
|
||
[<span class="ty">System.Serializable</span>] <span class="kw">public class</span> <span class="ty">BeatSageInfoDat</span>
|
||
{
|
||
<span class="kw">public string</span> _songName; <span class="ann">// JSON 키 "_songName"</span>
|
||
<span class="kw">public string</span> _songAuthorName; <span class="ann">// JSON 키 "_songAuthorName"</span>
|
||
<span class="kw">public float</span> _beatsPerMinute; <span class="ann">// Beat Sage가 오디오에서 자동 감지한 BPM</span>
|
||
}
|
||
|
||
<span class="ann">// .dat 파일 최상위 구조</span>
|
||
[<span class="ty">System.Serializable</span>] <span class="kw">public class</span> <span class="ty">BeatSageRoot</span>
|
||
{
|
||
<span class="kw">public string</span> _version;
|
||
<span class="kw">public</span> <span class="ty">List</span><<span class="ty">BeatSageNote</span>> _notes; <span class="ann">// 노트 배열</span>
|
||
}
|
||
|
||
[<span class="ty">System.Serializable</span>] <span class="kw">public class</span> <span class="ty">BeatSageNote</span>
|
||
{
|
||
<span class="kw">public float</span> _time; <span class="ann">// 비트 단위 시간</span>
|
||
<span class="kw">public int</span> _lineIndex; <span class="ann">// 열 0-3</span>
|
||
<span class="kw">public int</span> _lineLayer; <span class="ann">// 행 0-2</span>
|
||
<span class="kw">public int</span> _type; <span class="ann">// 0=빨강 1=파랑 3=폭탄</span>
|
||
<span class="kw">public int</span> _cutDirection; <span class="ann">// 0-8</span>
|
||
}
|
||
|
||
<span class="ann">// static class = 상태(필드) 없이 순수 함수만 모아놓는 유틸리티 패턴</span>
|
||
<span class="ann">// 인스턴스 없이 BeatSageConverter.Convert() 로 바로 호출</span>
|
||
<span class="kw">public static class</span> <span class="ty">BeatSageConverter</span>
|
||
{
|
||
<span class="ann">// rawJson: .dat 파일 전체 문자열 / bpm: info.dat에서 읽은 BPM</span>
|
||
<span class="kw">public static</span> <span class="ty">List</span><<span class="ty">NoteData</span>> <span class="fn">Convert</span>(<span class="ty">string</span> rawJson, <span class="ty">float</span> bpm)
|
||
{
|
||
<span class="kw">var</span> result = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">NoteData</span>>();
|
||
<span class="kw">var</span> root = JsonUtility.<span class="fn">FromJson</span><<span class="ty">BeatSageRoot</span>>(rawJson);
|
||
|
||
<span class="ann">// ?. = null 조건부 접근. root가 null이거나 _notes가 null이면 null 반환 → if가 잡아냄</span>
|
||
<span class="kw">if</span> (root?._notes == <span class="kw">null</span>)
|
||
{
|
||
Debug.<span class="fn">LogWarning</span>(<span class="st">"[BeatSageConverter] Parse failed or no notes."</span>);
|
||
<span class="kw">return</span> result; <span class="ann">// 빈 리스트 반환 (null이 아님 → 호출부에서 null 체크 불필요)</span>
|
||
}
|
||
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> note <span class="kw">in</span> root._notes)
|
||
{
|
||
<span class="ann">// 폭탄(_type=3), 벽 등은 스킵. 우리는 일반 블록(0,1)만 처리</span>
|
||
<span class="kw">if</span> (note._type != <span class="nm">0</span> && note._type != <span class="nm">1</span>) <span class="kw">continue</span>;
|
||
|
||
result.<span class="fn">Add</span>(<span class="kw">new</span> <span class="ty">NoteData</span>
|
||
{
|
||
<span class="ann">// ★ 핵심 변환: 비트 → 초</span>
|
||
<span class="ann">// 공식: 초 = 비트 × 60 / BPM</span>
|
||
<span class="ann">// 예) BPM=120, _time=8 → 8×60/120 = 4.0초</span>
|
||
time = (note._time * <span class="nm">60f</span>) / bpm,
|
||
position = note._lineIndex,
|
||
lineLayer = note._lineLayer,
|
||
colorType = note._type,
|
||
cutDirection = note._cutDirection,
|
||
});
|
||
}
|
||
|
||
Debug.<span class="fn">Log</span>($<span class="st">"[BeatSageConverter] Converted {result.Count} notes."</span>);
|
||
<span class="kw">return</span> result;
|
||
}
|
||
|
||
<span class="ann">// NoteData 리스트를 우리 맵 JSON 형식으로 직렬화</span>
|
||
<span class="ann">// true = prettyPrint (들여쓰기 포함 → 사람이 읽기 좋음)</span>
|
||
<span class="kw">public static string</span> <span class="fn">ToMapJson</span>(<span class="ty">List</span><<span class="ty">NoteData</span>> notes)
|
||
<span class="op">=></span> JsonUtility.<span class="fn">ToJson</span>(<span class="kw">new</span> <span class="ty">MapData</span> { target = notes }, prettyPrint: <span class="kw">true</span>);
|
||
|
||
<span class="ann">// info.dat JSON을 파싱해서 제목/아티스트/BPM 추출</span>
|
||
<span class="kw">public static</span> <span class="ty">SongMetadata</span> <span class="fn">ParseInfoDat</span>(<span class="ty">string</span> json)
|
||
{
|
||
<span class="kw">var</span> info = JsonUtility.<span class="fn">FromJson</span><<span class="ty">BeatSageInfoDat</span>>(json);
|
||
<span class="kw">if</span> (info == <span class="kw">null</span>) <span class="kw">return null</span>;
|
||
<span class="kw">return new</span> <span class="ty">SongMetadata</span>
|
||
{
|
||
<span class="ann">// ?? "" = null이면 빈 문자열로 대체 (NullReferenceException 방지)</span>
|
||
<span class="ann">// .Trim() = 앞뒤 공백 제거 (Beat Sage가 가끔 공백을 넣음)</span>
|
||
title = (info._songName ?? <span class="st">""</span>).<span class="fn">Trim</span>(),
|
||
artist = (info._songAuthorName ?? <span class="st">""</span>).<span class="fn">Trim</span>(),
|
||
bpm = info._beatsPerMinute,
|
||
};
|
||
}
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ BeatSageUploader.cs ══════════════════════ -->
|
||
<div id="p-uploader" class="panel">
|
||
<div class="file-header">
|
||
<h1>BeatSageUploader.cs</h1>
|
||
<p>Beat Sage 서버에 오디오를 올리고, 비트맵 생성 완료를 폴링하고, 결과 ZIP을 받아 변환하는 전체 파이프라인.</p>
|
||
</div>
|
||
<div class="box box-b"><div class="lbl">전체 흐름 (4단계)</div><p>[1/4] POST 오디오 → levelId 획득 → [2/4] GET /heartbeat 5초마다 폴링 → [3/4] GET /download ZIP 다운로드 → [4/4] ZIP 해제 + .dat → NoteData 변환</p></div>
|
||
<div class="cw"><div class="ch"><span>BeatSageUploader.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">BeatSageUploader</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// const = 컴파일 타임 상수. 런타임에 변경 불가. 문자열 상수는 항상 const로 선언하는 게 관례</span>
|
||
<span class="kw">private const string</span> BASE_URL = <span class="st">"https://beatsaber.com"</span>;
|
||
<span class="kw">private const string</span> CREATE_EP = <span class="st">"/beatsaber_custom_level_create"</span>;
|
||
<span class="kw">private const string</span> HEARTBEAT_EP = <span class="st">"/beatsaber_custom_level_heartbeat/{0}"</span>; <span class="ann">// {0} = levelId</span>
|
||
<span class="kw">private const string</span> DOWNLOAD_EP = <span class="st">"/beatsaber_custom_level_download/{0}"</span>;
|
||
|
||
<span class="kw">private const float</span> POLL_INTERVAL = <span class="nm">5f</span>; <span class="ann">// 5초마다 heartbeat 체크</span>
|
||
<span class="kw">private const float</span> POLL_TIMEOUT = <span class="nm">300f</span>; <span class="ann">// 최대 5분 대기. 초과하면 오류 처리</span>
|
||
|
||
<span class="ann">// Beat Sage API는 "Normal", "Hard"처럼 파스칼케이스를 요구</span>
|
||
<span class="ann">// 우리 내부는 "normal", "hard" 소문자 사용 → 이 딕셔너리로 매핑</span>
|
||
<span class="kw">private static readonly</span> <span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">string</span>> DiffNames = <span class="kw">new</span>()
|
||
{
|
||
{ <span class="st">"normal"</span>, <span class="st">"Normal"</span> }, { <span class="st">"hard"</span>, <span class="st">"Hard"</span> },
|
||
{ <span class="st">"expert"</span>, <span class="st">"Expert"</span> }, { <span class="st">"expertplus"</span>, <span class="st">"ExpertPlus"</span> },
|
||
};
|
||
|
||
<span class="ann">// ZIP 안의 파일명 매핑 (대소문자 정확히 일치해야 ZipArchive에서 찾음)</span>
|
||
<span class="kw">private static readonly</span> <span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">string</span>> DatFileNames = <span class="kw">new</span>()
|
||
{
|
||
{ <span class="st">"normal"</span>, <span class="st">"Normal.dat"</span> }, { <span class="st">"hard"</span>, <span class="st">"Hard.dat"</span> },
|
||
{ <span class="st">"expert"</span>, <span class="st">"Expert.dat"</span> }, { <span class="st">"expertplus"</span>, <span class="st">"ExpertPlus.dat"</span> },
|
||
};
|
||
|
||
<span class="ann">// { get; private set; } = 외부에서 읽기만 가능, 쓰기는 이 클래스 내부만 가능</span>
|
||
<span class="ann">// SongCreatorManager가 UI에 상태 텍스트를 표시하기 위해 읽는다</span>
|
||
<span class="kw">public string</span> CurrentStatus { <span class="kw">get</span>; <span class="kw">private set</span>; } = <span class="st">""</span>;
|
||
<span class="kw">public</span> <span class="ty">SongMetadata</span> LastMetadata { <span class="kw">get</span>; <span class="kw">private set</span>; } <span class="ann">// info.dat에서 읽은 제목/아티스트/BPM</span>
|
||
|
||
<span class="ann">// ── 공개 API: 로컬 파일 업로드 ──────────────────────────────</span>
|
||
<span class="ann">// IEnumerator = 코루틴. yield return으로 Unity에게 "여기서 한 프레임 쉬고 와"를 알림</span>
|
||
<span class="ann">// Action<T> = 반환값 없는 콜백 델리게이트. 코루틴은 값을 return 못하므로 콜백으로 결과 전달</span>
|
||
<span class="kw">public</span> <span class="ty">IEnumerator</span> <span class="fn">Upload</span>(
|
||
<span class="ty">string</span> audioPath, <span class="ann">// 로컬 MP3 경로</span>
|
||
<span class="ty">List</span><<span class="ty">string</span>> difficulties, <span class="ann">// ["normal","hard",...]</span>
|
||
<span class="ty">float</span> bpm, <span class="ann">// UI에서 입력한 BPM (fallback)</span>
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress, <span class="ann">// 0.0~1.0 진행률 콜백</span>
|
||
<span class="ty">Action</span><<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>>> onComplete, <span class="ann">// 성공: 변환된 맵 전달</span>
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onError) <span class="ann">// 실패: 오류 메시지 전달</span>
|
||
{
|
||
<span class="fn">SetStatus</span>(<span class="st">"[1/4] Uploading audio..."</span>);
|
||
<span class="ty">string</span> levelId = <span class="kw">null</span>;
|
||
|
||
<span class="ann">// yield return StartCoroutine() = 내부 코루틴이 끝날 때까지 여기서 기다린다</span>
|
||
<span class="ann">// levelId는 람다(id => levelId = id)를 통해 값을 받아온다</span>
|
||
<span class="kw">yield return</span> <span class="fn">CreateLevel</span>(audioPath, difficulties, id => levelId = id, onError);
|
||
<span class="kw">if</span> (levelId == <span class="kw">null</span>) <span class="kw">yield break</span>; <span class="ann">// CreateLevel이 오류를 호출했으면 중단</span>
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.15f</span>);
|
||
|
||
<span class="kw">yield return</span> <span class="fn">PollAndDownload</span>(levelId, difficulties, bpm, onProgress, onComplete, onError);
|
||
}
|
||
|
||
<span class="ann">// URL 업로드는 오디오 파일 대신 URL 문자열을 전송 → Beat Sage 서버가 직접 다운로드</span>
|
||
<span class="ann">// 이후 PollAndDownload는 동일하므로 공통 메서드로 분리되어 있음</span>
|
||
<span class="kw">public</span> <span class="ty">IEnumerator</span> <span class="fn">UploadFromUrl</span>(
|
||
<span class="ty">string</span> audioUrl, <span class="ty">List</span><<span class="ty">string</span>> difficulties, <span class="ty">float</span> bpm,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress,
|
||
<span class="ty">Action</span><<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>>> onComplete,
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="fn">SetStatus</span>(<span class="st">"[1/4] Sending URL to Beat Sage..."</span>);
|
||
<span class="ty">string</span> levelId = <span class="kw">null</span>;
|
||
<span class="kw">yield return</span> <span class="fn">CreateLevelFromUrl</span>(audioUrl, difficulties, id => levelId = id, onError);
|
||
<span class="kw">if</span> (levelId == <span class="kw">null</span>) <span class="kw">yield break</span>;
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.15f</span>);
|
||
<span class="kw">yield return</span> <span class="fn">PollAndDownload</span>(levelId, difficulties, bpm, onProgress, onComplete, onError);
|
||
}
|
||
|
||
<span class="ann">// ── 2~4단계 공통 파이프라인 ──────────────────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">PollAndDownload</span>(
|
||
<span class="ty">string</span> levelId, <span class="ty">List</span><<span class="ty">string</span>> difficulties, <span class="ty">float</span> bpm,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress,
|
||
<span class="ty">Action</span><<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>>> onComplete,
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="fn">SetStatus</span>(<span class="st">"[2/4] Generating beatmap..."</span>);
|
||
<span class="kw">bool</span> ready = <span class="kw">false</span>;
|
||
<span class="kw">float</span> elapsed = <span class="nm">0f</span>;
|
||
|
||
<span class="ann">// Beat Sage 생성 완료 폴링: 5초마다 상태 확인, 최대 300초 대기</span>
|
||
<span class="kw">while</span> (!ready && elapsed < POLL_TIMEOUT)
|
||
{
|
||
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(POLL_INTERVAL); <span class="ann">// 5초 대기 (Unity 메인 스레드 블로킹 없음)</span>
|
||
elapsed += POLL_INTERVAL;
|
||
|
||
<span class="kw">bool</span> error = <span class="kw">false</span>;
|
||
<span class="kw">yield return</span> <span class="fn">PollHeartbeat</span>(levelId,
|
||
status => {
|
||
<span class="ann">// OrdinalIgnoreCase = 대소문자 무시 비교. "Generated" vs "generated" 둘 다 인식</span>
|
||
ready = string.<span class="fn">Equals</span>(status, <span class="st">"generated"</span>, StringComparison.OrdinalIgnoreCase)
|
||
|| string.<span class="fn">Equals</span>(status, <span class="st">"done"</span>, StringComparison.OrdinalIgnoreCase);
|
||
error = string.<span class="fn">Equals</span>(status, <span class="st">"error"</span>, StringComparison.OrdinalIgnoreCase);
|
||
},
|
||
onError);
|
||
|
||
<span class="kw">if</span> (error) { onError?.<span class="fn">Invoke</span>(<span class="st">"Beat Sage generation failed"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// 진행률: 15%~75% 구간을 폴링 시간 비율로 채움</span>
|
||
<span class="ann">// Clamp01 = 0~1 사이로 클램프 (elapsed가 POLL_TIMEOUT 초과해도 1.0 이상 안 됨)</span>
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.15f</span> + Mathf.<span class="fn">Clamp01</span>(elapsed / POLL_TIMEOUT) * <span class="nm">0.6f</span>);
|
||
<span class="fn">SetStatus</span>($<span class="st">"[2/4] Generating... {(int)elapsed}s elapsed"</span>);
|
||
}
|
||
|
||
<span class="kw">if</span> (!ready) { onError?.<span class="fn">Invoke</span>(<span class="st">"Beat Sage timeout (>5 min)"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="fn">SetStatus</span>(<span class="st">"[3/4] Downloading result..."</span>);
|
||
<span class="kw">byte</span>[] zipBytes = <span class="kw">null</span>;
|
||
<span class="kw">yield return</span> <span class="fn">DownloadZip</span>(levelId, b => zipBytes = b, onError);
|
||
<span class="kw">if</span> (zipBytes == <span class="kw">null</span>) <span class="kw">yield break</span>;
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.9f</span>);
|
||
|
||
<span class="fn">SetStatus</span>(<span class="st">"[3/4] Converting map data..."</span>);
|
||
<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> maps = <span class="kw">null</span>;
|
||
<span class="ann">// try-catch: ZIP 파싱/변환은 동기 작업이므로 코루틴 밖에서 예외가 바로 던져짐</span>
|
||
<span class="ann">// 코루틴 안에서 예외가 나면 Unity가 조용히 중단하므로 명시적으로 잡아야 한다</span>
|
||
<span class="kw">try</span> { maps = <span class="fn">ExtractAndConvert</span>(zipBytes, difficulties, bpm); }
|
||
<span class="kw">catch</span> (<span class="ty">Exception</span> e) { onError?.<span class="fn">Invoke</span>($<span class="st">"Conversion failed: {e.Message}"</span>); <span class="kw">yield break</span>; }
|
||
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">1f</span>);
|
||
onComplete?.<span class="fn">Invoke</span>(maps); <span class="ann">// 성공! 변환된 맵을 SongCreatorManager에 전달</span>
|
||
}
|
||
|
||
<span class="ann">// ── CreateLevel: 파일을 multipart/form-data로 POST ──────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">CreateLevel</span>(<span class="ty">string</span> audioPath, <span class="ty">List</span><<span class="ty">string</span>> difficulties,
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onSuccess, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="kw">byte</span>[] audioBytes = File.<span class="fn">ReadAllBytes</span>(audioPath); <span class="ann">// 파일 전체를 메모리로 읽음</span>
|
||
<span class="kw">string</span> fileName = Path.<span class="fn">GetFileName</span>(audioPath);
|
||
|
||
<span class="ann">// 내부 "normal" 키를 API가 요구하는 "Normal" 등으로 변환</span>
|
||
<span class="kw">var</span> mappedDiffs = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>>();
|
||
<span class="kw">foreach</span> (<span class="ty">string</span> d <span class="kw">in</span> difficulties)
|
||
<span class="kw">if</span> (DiffNames.<span class="fn">TryGetValue</span>(d, <span class="kw">out var</span> n)) mappedDiffs.<span class="fn">Add</span>(n);
|
||
|
||
<span class="kw">if</span> (mappedDiffs.Count == <span class="nm">0</span>) { onError?.<span class="fn">Invoke</span>(<span class="st">"No supported difficulties."</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// IMultipartFormSection 리스트로 form-data 구성</span>
|
||
<span class="ann">// MultipartFormFileSection = 파일 파트 (Content-Disposition: form-data; name=".."; filename="..")</span>
|
||
<span class="ann">// MultipartFormDataSection = 텍스트 파트</span>
|
||
<span class="kw">var</span> form = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">IMultipartFormSection</span>>
|
||
{
|
||
<span class="kw">new</span> <span class="ty">MultipartFormFileSection</span>(<span class="st">"audio_file"</span>, audioBytes, fileName, <span class="st">"audio/mpeg"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"audio_metadata_title"</span>, <span class="st">" "</span>), <span class="ann">// Beat Sage가 info.dat로 자동 감지</span>
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"audio_metadata_artist"</span>, <span class="st">" "</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"difficulties"</span>, string.<span class="fn">Join</span>(<span class="st">","</span>, mappedDiffs)), <span class="ann">// "Normal,Hard,Expert"</span>
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"modes"</span>, <span class="st">"Standard"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"events"</span>, <span class="st">"DotBlocks,Obstacles,Bombs"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"environment"</span>, <span class="st">"DefaultEnvironment"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"system_tag"</span>, <span class="st">"v2"</span>),
|
||
};
|
||
|
||
<span class="ann">// using var = C# 8 선언형 using. 이 변수가 스코프를 벗어나면 자동 Dispose (메모리 해제)</span>
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Post</span>(BASE_URL + CREATE_EP, form);
|
||
req.<span class="fn">SetRequestHeader</span>(<span class="st">"Accept"</span>, <span class="st">"*/*"</span>);
|
||
req.<span class="fn">SetRequestHeader</span>(<span class="st">"User-Agent"</span>, <span class="st">"VRBeatSaber/1.0"</span>);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>(); <span class="ann">// 요청 완료까지 대기</span>
|
||
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Level create failed: {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// 응답 예: {"id":"abc123def"} → ParseJsonString으로 "abc123def" 추출</span>
|
||
<span class="ty">string</span> levelId = <span class="fn">ParseJsonString</span>(req.downloadHandler.text, <span class="st">"id"</span>);
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(levelId))
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Failed to parse levelId: {req.downloadHandler.text}"</span>); <span class="kw">yield break</span>; }
|
||
|
||
onSuccess?.<span class="fn">Invoke</span>(levelId);
|
||
}
|
||
|
||
<span class="ann">// URL 버전 — 파일 바이트 대신 audio_url 필드에 URL 문자열을 보냄</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">CreateLevelFromUrl</span>(<span class="ty">string</span> audioUrl, <span class="ty">List</span><<span class="ty">string</span>> difficulties,
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onSuccess, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="kw">var</span> mappedDiffs = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>>();
|
||
<span class="kw">foreach</span> (<span class="ty">string</span> d <span class="kw">in</span> difficulties)
|
||
<span class="kw">if</span> (DiffNames.<span class="fn">TryGetValue</span>(d, <span class="kw">out var</span> n)) mappedDiffs.<span class="fn">Add</span>(n);
|
||
<span class="kw">if</span> (mappedDiffs.Count == <span class="nm">0</span>) { onError?.<span class="fn">Invoke</span>(<span class="st">"No supported difficulties."</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="kw">var</span> form = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">IMultipartFormSection</span>>
|
||
{
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"audio_url"</span>, audioUrl), <span class="ann">// 파일 업로드 대신 URL</span>
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"audio_metadata_title"</span>, <span class="st">" "</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"audio_metadata_artist"</span>, <span class="st">" "</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"difficulties"</span>, string.<span class="fn">Join</span>(<span class="st">","</span>, mappedDiffs)),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"modes"</span>, <span class="st">"Standard"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"events"</span>, <span class="st">"DotBlocks,Obstacles,Bombs"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"environment"</span>, <span class="st">"DefaultEnvironment"</span>),
|
||
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="st">"system_tag"</span>, <span class="st">"v2"</span>),
|
||
};
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Post</span>(BASE_URL + CREATE_EP, form);
|
||
req.<span class="fn">SetRequestHeader</span>(<span class="st">"Accept"</span>, <span class="st">"*/*"</span>); req.<span class="fn">SetRequestHeader</span>(<span class="st">"User-Agent"</span>, <span class="st">"VRBeatSaber/1.0"</span>);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Level create (URL) failed: {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
<span class="ty">string</span> levelId = <span class="fn">ParseJsonString</span>(req.downloadHandler.text, <span class="st">"id"</span>);
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(levelId))
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Failed to parse levelId: {req.downloadHandler.text}"</span>); <span class="kw">yield break</span>; }
|
||
onSuccess?.<span class="fn">Invoke</span>(levelId);
|
||
}
|
||
|
||
<span class="ann">// ── Heartbeat: 생성 완료 여부 1회 체크 ──────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">PollHeartbeat</span>(<span class="ty">string</span> levelId,
|
||
<span class="ty">Action</span><<span class="ty">string</span>> onStatus, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ann">// string.Format = {0} 자리에 levelId 삽입</span>
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(BASE_URL + string.<span class="fn">Format</span>(HEARTBEAT_EP, levelId));
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Heartbeat failed: {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
<span class="ann">// ?? "" = status가 null이면 빈 문자열 (PollAndDownload의 Equals가 null에 안전)</span>
|
||
onStatus?.<span class="fn">Invoke</span>(<span class="fn">ParseJsonString</span>(req.downloadHandler.text, <span class="st">"status"</span>) ?? <span class="st">""</span>);
|
||
}
|
||
|
||
<span class="ann">// ── ZIP 다운로드: 서버 500 오류 시 최대 3회 재시도 ────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">DownloadZip</span>(<span class="ty">string</span> levelId,
|
||
<span class="ty">Action</span><<span class="ty">byte</span>[]> onSuccess, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ty">string</span> url = BASE_URL + string.<span class="fn">Format</span>(DOWNLOAD_EP, levelId);
|
||
|
||
<span class="ann">// 재시도 루프: attempt 1~3</span>
|
||
<span class="kw">for</span> (<span class="kw">int</span> attempt = <span class="nm">1</span>; attempt <= <span class="nm">3</span>; attempt++)
|
||
{
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
|
||
<span class="kw">if</span> (req.result == <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onSuccess?.<span class="fn">Invoke</span>(req.downloadHandler.data); <span class="kw">yield break</span>; } <span class="ann">// 성공 → byte[] 전달</span>
|
||
|
||
<span class="ann">// HTTP 500 = 서버 내부 오류. Beat Sage가 처리 중일 때 발생할 수 있어 재시도</span>
|
||
<span class="kw">if</span> (req.responseCode == <span class="nm">500</span> && attempt < <span class="nm">3</span>)
|
||
{
|
||
<span class="fn">SetStatus</span>($<span class="st">"[3/4] Server error, retrying ({attempt}/3)..."</span>);
|
||
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(<span class="nm">5f</span>);
|
||
<span class="kw">continue</span>; <span class="ann">// 다음 시도로</span>
|
||
}
|
||
onError?.<span class="fn">Invoke</span>($<span class="st">"ZIP download failed: {req.error} (HTTP {req.responseCode})"</span>);
|
||
<span class="kw">yield break</span>;
|
||
}
|
||
}
|
||
|
||
<span class="ann">// ── ZIP 해제 + 각 .dat 파일을 NoteData로 변환 ────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> <span class="fn">ExtractAndConvert</span>(
|
||
<span class="kw">byte</span>[] zipBytes, <span class="ty">List</span><<span class="ty">string</span>> difficulties, <span class="ty">float</span> fallbackBpm)
|
||
{
|
||
<span class="kw">var</span> result = <span class="kw">new</span> <span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>>();
|
||
|
||
<span class="ann">// byte[]를 MemoryStream으로 감싸면 파일 없이 ZipArchive로 다룰 수 있다</span>
|
||
<span class="kw">using var</span> ms = <span class="kw">new</span> <span class="ty">MemoryStream</span>(zipBytes);
|
||
<span class="kw">using var</span> archive = <span class="kw">new</span> <span class="ty">ZipArchive</span>(ms, ZipArchiveMode.Read);
|
||
|
||
<span class="ann">// info.dat 먼저 파싱 → BPM 및 메타데이터 획득</span>
|
||
<span class="kw">float</span> bpm = fallbackBpm;
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> e <span class="kw">in</span> archive.Entries)
|
||
{
|
||
<span class="kw">if</span> (!string.<span class="fn">Equals</span>(e.Name, <span class="st">"info.dat"</span>, StringComparison.OrdinalIgnoreCase)) <span class="kw">continue</span>;
|
||
<span class="kw">using var</span> r = <span class="kw">new</span> <span class="ty">StreamReader</span>(e.<span class="fn">Open</span>(), Encoding.UTF8);
|
||
<span class="kw">var</span> meta = <span class="ty">BeatSageConverter</span>.<span class="fn">ParseInfoDat</span>(r.<span class="fn">ReadToEnd</span>());
|
||
<span class="kw">if</span> (meta != <span class="kw">null</span>)
|
||
{
|
||
LastMetadata = meta; <span class="ann">// SongCreatorManager가 이 값으로 UI 자동완성</span>
|
||
<span class="kw">if</span> (meta.bpm > <span class="nm">0</span>) bpm = meta.bpm; <span class="ann">// info.dat BPM이 있으면 우선 사용</span>
|
||
}
|
||
<span class="kw">break</span>;
|
||
}
|
||
|
||
<span class="ann">// 각 난이도 .dat 파일을 찾아 변환</span>
|
||
<span class="kw">foreach</span> (<span class="ty">string</span> diff <span class="kw">in</span> difficulties)
|
||
{
|
||
<span class="kw">if</span> (!DatFileNames.<span class="fn">TryGetValue</span>(diff, <span class="kw">out</span> <span class="ty">string</span> datName)) <span class="kw">continue</span>;
|
||
|
||
<span class="ty">ZipArchiveEntry</span> entry = <span class="kw">null</span>;
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> e <span class="kw">in</span> archive.Entries)
|
||
<span class="kw">if</span> (string.<span class="fn">Equals</span>(e.Name, datName, StringComparison.OrdinalIgnoreCase))
|
||
{ entry = e; <span class="kw">break</span>; }
|
||
|
||
<span class="kw">if</span> (entry == <span class="kw">null</span>) { Debug.<span class="fn">LogWarning</span>($<span class="st">"{datName} not found"</span>); <span class="kw">continue</span>; }
|
||
|
||
<span class="kw">using var</span> reader = <span class="kw">new</span> <span class="ty">StreamReader</span>(entry.<span class="fn">Open</span>(), Encoding.UTF8);
|
||
<span class="ann">// BeatSageConverter.Convert = .dat JSON → NoteData 리스트 (비트→초 변환 포함)</span>
|
||
result[diff] = <span class="ty">BeatSageConverter</span>.<span class="fn">Convert</span>(reader.<span class="fn">ReadToEnd</span>(), bpm);
|
||
}
|
||
<span class="kw">return</span> result;
|
||
}
|
||
|
||
<span class="ann">// ── 간이 JSON 파서: {"key":"value"} 에서 value만 추출 ────────</span>
|
||
<span class="ann">// Newtonsoft.Json 없이 단순 응답을 파싱하기 위한 수동 구현</span>
|
||
<span class="ann">// 주의: 값에 이스케이프된 따옴표(\")가 있으면 틀린다 → 단순 응답에만 사용</span>
|
||
<span class="kw">private static string</span> <span class="fn">ParseJsonString</span>(<span class="ty">string</span> json, <span class="ty">string</span> key)
|
||
{
|
||
<span class="ty">string</span> search = $<span class="st">"\"{key}\":"</span>; <span class="ann">// 예: "id":" 를 찾는다</span>
|
||
<span class="kw">int</span> start = json.<span class="fn">IndexOf</span>(search, StringComparison.Ordinal);
|
||
<span class="kw">if</span> (start < <span class="nm">0</span>) <span class="kw">return null</span>; <span class="ann">// 키 없음</span>
|
||
start += search.Length; <span class="ann">// 값 시작 위치로 이동</span>
|
||
<span class="kw">int</span> end = json.<span class="fn">IndexOf</span>(<span class="st">'"'</span>, start); <span class="ann">// 닫는 " 찾기</span>
|
||
<span class="kw">return</span> end > start ? json.<span class="fn">Substring</span>(start, end - start) : <span class="kw">null</span>;
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">SetStatus</span>(<span class="ty">string</span> msg) => CurrentStatus = msg;
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
|
||
<!-- ══════════════════════ NasPublisher.cs ══════════════════════ -->
|
||
<div id="p-naspub" class="panel">
|
||
<div class="file-header">
|
||
<h1>NasPublisher.cs</h1>
|
||
<p>Synology DSM FileStation API로 NAS에 MP3·맵 JSON을 업로드하고 <code>songs.json</code>을 갱신한다.</p>
|
||
</div>
|
||
<div class="box box-r"><div class="lbl">왜 수동 multipart?</div><p>Unity의 <code>UnityWebRequest.Post(form)</code>가 생성하는 multipart boundary 포맷을 DSM이 거부(401)한다. GUID boundary를 직접 만들고 CRLF 줄바꿈을 손으로 조립해야 DSM이 받아들인다.</p></div>
|
||
<div class="cw"><div class="ch"><span>NasPublisher.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">NasPublisher</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// [Header] = Inspector에서 구분선+라벨 표시. 코드에는 영향 없음</span>
|
||
[Header(<span class="st">"NAS Connection"</span>)]
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private string</span> nasBaseUrl = <span class="st">"http://192.168.55.3:5000"</span>; <span class="ann">// 로컬 NAS IP</span>
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private string</span> nasAccount = <span class="st">"admin"</span>;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private string</span> nasRootPath = <span class="st">"/web/beatsaber"</span>;
|
||
|
||
[Header(<span class="st">"Static Server URL"</span>)]
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private string</span> staticBaseUrl = <span class="st">"http://whdwo798.synology.me/beatsaber"</span>;
|
||
|
||
<span class="kw">private string</span> _sid = <span class="st">""</span>; <span class="ann">// DSM 로그인 세션 ID. 모든 API 요청에 포함</span>
|
||
<span class="kw">private string</span> _synoToken = <span class="st">""</span>; <span class="ann">// CSRF 방지 토큰. 업로드 헤더에 추가</span>
|
||
<span class="kw">private string</span> _password = <span class="st">""</span>; <span class="ann">// nas_config.json에서 읽음. 코드에 하드코딩 금지</span>
|
||
|
||
<span class="ann">// Awake = Start보다 먼저 실행. 다른 컴포넌트 Start()가 실행되기 전에 설정 완료</span>
|
||
<span class="kw">private void</span> <span class="fn">Awake</span>() => <span class="fn">LoadConfig</span>();
|
||
|
||
<span class="kw">private void</span> <span class="fn">LoadConfig</span>()
|
||
{
|
||
<span class="ann">// StreamingAssets = APK/앱 빌드 후에도 읽기 가능한 에셋 폴더</span>
|
||
<span class="ann">// 비밀번호를 코드에 넣지 않고 별도 파일로 관리 (gitignore에 추가됨)</span>
|
||
<span class="ty">string</span> path = Path.<span class="fn">Combine</span>(Application.streamingAssetsPath, <span class="st">"nas_config.json"</span>);
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(path)) { Debug.<span class="fn">LogWarning</span>(<span class="st">"nas_config.json not found"</span>); <span class="kw">return</span>; }
|
||
<span class="kw">var</span> cfg = JsonUtility.<span class="fn">FromJson</span><<span class="ty">NasConfig</span>>(File.<span class="fn">ReadAllText</span>(path));
|
||
<span class="kw">if</span> (cfg == <span class="kw">null</span>) <span class="kw">return</span>;
|
||
_password = cfg.password ?? <span class="st">""</span>;
|
||
<span class="ann">// cfg 값이 있으면 Inspector 기본값을 덮어씀 → 환경별로 config만 바꾸면 됨</span>
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(cfg.host)) nasBaseUrl = cfg.host;
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(cfg.account)) nasAccount = cfg.account;
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(cfg.rootPath)) nasRootPath = cfg.rootPath;
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
|
||
}
|
||
|
||
<span class="ann">// private nested class = NasPublisher 안에서만 쓰는 JSON 역직렬화용 구조체</span>
|
||
[<span class="ty">Serializable</span>] <span class="kw">private class</span> <span class="ty">NasConfig</span>
|
||
{
|
||
<span class="kw">public string</span> host; <span class="kw">public string</span> account; <span class="kw">public string</span> rootPath;
|
||
<span class="kw">public string</span> staticUrl; <span class="kw">public string</span> password;
|
||
}
|
||
|
||
<span class="ann">// ── 메인 업로드 파이프라인 ──────────────────────────────────</span>
|
||
<span class="kw">public</span> <span class="ty">IEnumerator</span> <span class="fn">Publish</span>(
|
||
<span class="ty">SongInfo</span> song,
|
||
<span class="ty">string</span> audioPath, <span class="ann">// null이면 오디오 업로드 스킵 (URL 모드)</span>
|
||
<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> maps,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
<span class="ann">// 로컬 함수 = 이 메서드 안에서만 쓰는 인라인 함수. failed 플래그와 onError를 캡처</span>
|
||
<span class="kw">void</span> <span class="fn">OnErr</span>(<span class="ty">string</span> e) { onError?.<span class="fn">Invoke</span>(e); failed = <span class="kw">true</span>; }
|
||
|
||
<span class="kw">yield return</span> <span class="fn">Login</span>(OnErr);
|
||
<span class="ann">// 로그인 성공 여부는 _sid가 비어있는지로 판단</span>
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(_sid)) <span class="kw">yield break</span>;
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.1f</span>);
|
||
|
||
<span class="ann">// URL 모드(audioPath==null)면 오디오는 이미 NAS에 있으므로 스킵</span>
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(audioPath))
|
||
{
|
||
<span class="ann">// {nasRootPath}/music 폴더에 {song.id}.mp3 로 업로드</span>
|
||
<span class="kw">yield return</span> <span class="fn">UploadFile</span>(audioPath, $<span class="st">"{nasRootPath}/music"</span>, $<span class="st">"{song.id}.mp3"</span>, OnErr);
|
||
<span class="kw">if</span> (failed) { <span class="kw">yield return</span> <span class="fn">Logout</span>(); <span class="kw">yield break</span>; }
|
||
}
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.4f</span>);
|
||
|
||
<span class="kw">int</span> total = maps.Count, done = <span class="nm">0</span>;
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> kv <span class="kw">in</span> maps) <span class="ann">// kv.Key = "normal", kv.Value = List<NoteData></span>
|
||
{
|
||
<span class="ty">string</span> fileName = $<span class="st">"Map_{song.id}_{kv.Key}.json"</span>; <span class="ann">// 예: Map_my_song_hard.json</span>
|
||
<span class="kw">byte</span>[] bytes = Encoding.UTF8.<span class="fn">GetBytes</span>(<span class="ty">BeatSageConverter</span>.<span class="fn">ToMapJson</span>(kv.Value));
|
||
<span class="ann">// song의 DifficultyInfo.mapFile 필드를 채워줌 → songs.json에 경로가 기록됨</span>
|
||
<span class="fn">AssignMapFile</span>(song, kv.Key, fileName);
|
||
|
||
<span class="kw">yield return</span> <span class="fn">UploadBytes</span>(bytes, fileName, $<span class="st">"{nasRootPath}/maps"</span>, OnErr);
|
||
<span class="kw">if</span> (failed) { <span class="kw">yield return</span> <span class="fn">Logout</span>(); <span class="kw">yield break</span>; }
|
||
|
||
done++;
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.4f</span> + (<span class="kw">float</span>)done / total * <span class="nm">0.3f</span>);
|
||
}
|
||
|
||
<span class="kw">yield return</span> <span class="fn">PatchSongsJson</span>(song, OnErr); <span class="ann">// songs.json 갱신 (upsert)</span>
|
||
<span class="kw">if</span> (failed) { <span class="kw">yield return</span> <span class="fn">Logout</span>(); <span class="kw">yield break</span>; }
|
||
|
||
<span class="kw">yield return</span> <span class="fn">Logout</span>();
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">1f</span>);
|
||
onComplete?.<span class="fn">Invoke</span>();
|
||
}
|
||
|
||
<span class="ann">// ── DSM 로그인: SID + SynoToken 획득 ──────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">Login</span>(<span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ann">// DSM Auth API: GET 요청으로 파라미터를 쿼리스트링으로 전달</span>
|
||
<span class="ann">// EscapeURL = 특수문자를 URL 인코딩 (비밀번호에 @, & 등이 있을 때 필요)</span>
|
||
<span class="ty">string</span> url = $<span class="st">"{nasBaseUrl}/webapi/auth.cgi"</span>
|
||
+ $<span class="st">"?api=SYNO.API.Auth&version=6&method=login"</span>
|
||
+ $<span class="st">"&account={UnityWebRequest.EscapeURL(nasAccount)}"</span>
|
||
+ $<span class="st">"&passwd={UnityWebRequest.EscapeURL(_password)}"</span>
|
||
+ $<span class="st">"&session=FileStation&format=sid&enable_syno_token=yes"</span>;
|
||
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"DSM login failed: {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ty">string</span> resp = req.downloadHandler.text;
|
||
_sid = <span class="fn">ParseJsonString</span>(resp, <span class="st">"sid"</span>); <span class="ann">// 세션 ID</span>
|
||
_synoToken = <span class="fn">ParseJsonString</span>(resp, <span class="st">"synotoken"</span>); <span class="ann">// CSRF 토큰 (DSM 7.x 필수)</span>
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(_sid))
|
||
onError?.<span class="fn">Invoke</span>(<span class="st">"DSM sid parse failed — check credentials."</span>);
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">Logout</span>()
|
||
{
|
||
<span class="ty">string</span> url = $<span class="st">"{nasBaseUrl}/webapi/auth.cgi?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}"</span>;
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
_sid = <span class="st">""</span>; <span class="ann">// 세션 무효화</span>
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">UploadFile</span>(<span class="ty">string</span> localPath, <span class="ty">string</span> nasFolder, <span class="ty">string</span> fileName, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
<span class="op">=></span> <span class="fn">UploadBytes</span>(File.<span class="fn">ReadAllBytes</span>(localPath), fileName, nasFolder, onError);
|
||
|
||
<span class="ann">// ── 수동 multipart/form-data 바디 조립 ────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">UploadBytes</span>(<span class="kw">byte</span>[] bytes, <span class="ty">string</span> fileName,
|
||
<span class="ty">string</span> nasFolder, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ty">string</span> uploadUrl = $<span class="st">"{nasBaseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Upload&version=2&method=upload&_sid={UnityWebRequest.EscapeURL(_sid)}"</span>;
|
||
|
||
<span class="ann">// GUID("N" 형식) = 하이픈 없는 32자리 랜덤 문자열. boundary로 사용</span>
|
||
<span class="ann">// boundary는 바디 안 데이터와 충돌하지 않는 유일한 구분자여야 한다</span>
|
||
<span class="ty">string</span> boundary = Guid.<span class="fn">NewGuid</span>().<span class="fn">ToString</span>(<span class="st">"N"</span>);
|
||
<span class="kw">const string</span> CRLF = <span class="st">"\r\n"</span>; <span class="ann">// HTTP 규격상 줄바꿈은 반드시 CRLF(\r\n)</span>
|
||
|
||
<span class="ann">// MemoryStream에 multipart 바디를 직접 조립</span>
|
||
<span class="kw">using var</span> body = <span class="kw">new</span> <span class="ty">MemoryStream</span>();
|
||
<span class="ann">// 로컬 함수: 문자열을 UTF-8 바이트로 변환해서 스트림에 쓰기</span>
|
||
<span class="kw">void</span> <span class="fn">WriteText</span>(<span class="ty">string</span> s) { <span class="kw">var</span> b = Encoding.UTF8.<span class="fn">GetBytes</span>(s); body.<span class="fn">Write</span>(b, <span class="nm">0</span>, b.Length); }
|
||
<span class="kw">void</span> <span class="fn">WriteField</span>(<span class="ty">string</span> name, <span class="ty">string</span> value)
|
||
{
|
||
<span class="fn">WriteText</span>($<span class="st">"--{boundary}{CRLF}"</span>);
|
||
<span class="fn">WriteText</span>($<span class="st">"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}"</span>);
|
||
<span class="fn">WriteText</span>(value + CRLF);
|
||
}
|
||
|
||
<span class="ann">// DSM FileStation.Upload API가 요구하는 필드</span>
|
||
<span class="fn">WriteField</span>(<span class="st">"path"</span>, nasFolder); <span class="ann">// 업로드 대상 NAS 폴더</span>
|
||
<span class="fn">WriteField</span>(<span class="st">"create_parents"</span>, <span class="st">"true"</span>); <span class="ann">// 폴더 없으면 자동 생성</span>
|
||
<span class="fn">WriteField</span>(<span class="st">"overwrite"</span>, <span class="st">"true"</span>); <span class="ann">// 동일 파일명 덮어쓰기</span>
|
||
|
||
<span class="ann">// 파일 파트: boundary 뒤에 Content-Disposition + Content-Type + 실제 바이트</span>
|
||
<span class="fn">WriteText</span>($<span class="st">"--{boundary}{CRLF}"</span>);
|
||
<span class="fn">WriteText</span>($<span class="st">"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}"</span>);
|
||
<span class="fn">WriteText</span>($<span class="st">"Content-Type: application/octet-stream{CRLF}{CRLF}"</span>);
|
||
body.<span class="fn">Write</span>(bytes, <span class="nm">0</span>, bytes.Length); <span class="ann">// 바이너리 데이터 직접 쓰기</span>
|
||
<span class="fn">WriteText</span>(CRLF + $<span class="st">"--{boundary}--{CRLF}"</span>); <span class="ann">// 종료 boundary (뒤에 -- 붙음)</span>
|
||
|
||
<span class="kw">using var</span> req = <span class="kw">new</span> <span class="ty">UnityWebRequest</span>(uploadUrl, <span class="st">"POST"</span>);
|
||
req.uploadHandler = <span class="kw">new</span> <span class="ty">UploadHandlerRaw</span>(body.<span class="fn">ToArray</span>()); <span class="ann">// 조립한 바디를 raw bytes로</span>
|
||
req.downloadHandler = <span class="kw">new</span> <span class="ty">DownloadHandlerBuffer</span>(); <span class="ann">// 응답을 메모리에 받음</span>
|
||
req.<span class="fn">SetRequestHeader</span>(<span class="st">"Content-Type"</span>, $<span class="st">"multipart/form-data; boundary={boundary}"</span>);
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(_synoToken))
|
||
req.<span class="fn">SetRequestHeader</span>(<span class="st">"X-SYNO-TOKEN"</span>, _synoToken); <span class="ann">// DSM 7.x CSRF 토큰</span>
|
||
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ onError?.<span class="fn">Invoke</span>($<span class="st">"Upload failed ({fileName}): {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
<span class="ann">// DSM은 HTTP 200이어도 바디에 "success":false 를 넣어 오류를 알릴 수 있다</span>
|
||
<span class="kw">if</span> (req.downloadHandler.text.<span class="fn">Contains</span>(<span class="st">"\"success\":false"</span>))
|
||
onError?.<span class="fn">Invoke</span>($<span class="st">"Upload rejected ({fileName}): {req.downloadHandler.text}"</span>);
|
||
}
|
||
|
||
<span class="ann">// ── songs.json 갱신 (upsert) ──────────────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">PatchSongsJson</span>(<span class="ty">SongInfo</span> newSong, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ty">SongsList</span> list = <span class="kw">null</span>;
|
||
<span class="ann">// 현재 songs.json 읽기 (실패해도 새로 만들면 되므로 onError 없음)</span>
|
||
<span class="kw">using</span> (<span class="kw">var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>($<span class="st">"{staticBaseUrl}/songs.json"</span>))
|
||
{
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result == <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
list = JsonUtility.<span class="fn">FromJson</span><<span class="ty">SongsList</span>>(req.downloadHandler.text);
|
||
}
|
||
<span class="ann">// ??= : list가 null이면 새 SongsList 할당 (C# 8 null 병합 할당)</span>
|
||
list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version = <span class="st">"1.0"</span>, songs = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">SongInfo</span>>() };
|
||
|
||
<span class="ann">// upsert: 같은 id가 있으면 교체, 없으면 추가</span>
|
||
<span class="ann">// FindIndex: 조건 람다를 만족하는 첫 번째 인덱스. 없으면 -1</span>
|
||
<span class="kw">int</span> idx = list.songs.<span class="fn">FindIndex</span>(s => s.id == newSong.id);
|
||
<span class="kw">if</span> (idx >= <span class="nm">0</span>) list.songs[idx] = newSong;
|
||
<span class="kw">else</span> list.songs.<span class="fn">Add</span>(newSong);
|
||
|
||
<span class="ann">// 갱신된 JSON을 NAS에 다시 업로드</span>
|
||
<span class="kw">yield return</span> <span class="fn">UploadBytes</span>(
|
||
Encoding.UTF8.<span class="fn">GetBytes</span>(JsonUtility.<span class="fn">ToJson</span>(list, <span class="kw">true</span>)),
|
||
<span class="st">"songs.json"</span>, nasRootPath, onError);
|
||
}
|
||
|
||
<span class="kw">private static string</span> <span class="fn">ParseJsonString</span>(<span class="ty">string</span> json, <span class="ty">string</span> key)
|
||
{
|
||
<span class="ty">string</span> search = $<span class="st">"\"{key}\":"</span>;
|
||
<span class="kw">int</span> start = json.<span class="fn">IndexOf</span>(search, StringComparison.Ordinal);
|
||
<span class="kw">if</span> (start < <span class="nm">0</span>) <span class="kw">return null</span>;
|
||
start += search.Length;
|
||
<span class="kw">int</span> end = json.<span class="fn">IndexOf</span>(<span class="st">'"'</span>, start);
|
||
<span class="kw">return</span> end > start ? json.<span class="fn">Substring</span>(start, end - start) : <span class="kw">null</span>;
|
||
}
|
||
|
||
<span class="ann">// DifficultyInfo.mapFile 필드에 NAS 상대 경로를 채워줌</span>
|
||
<span class="ann">// 이 값이 songs.json에 기록되어 나중에 DownloadManager가 다운로드 URL을 조합함</span>
|
||
<span class="kw">private static void</span> <span class="fn">AssignMapFile</span>(<span class="ty">SongInfo</span> song, <span class="ty">string</span> diff, <span class="ty">string</span> fileName)
|
||
{
|
||
<span class="kw">var</span> info = song.difficulties.<span class="fn">Get</span>(diff);
|
||
<span class="kw">if</span> (info != <span class="kw">null</span>) info.mapFile = $<span class="st">"maps/{fileName}"</span>;
|
||
}
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ DownloadManager.cs ══════════════════════ -->
|
||
<div id="p-dlmgr" class="panel">
|
||
<div class="file-header">
|
||
<h1>DownloadManager.cs</h1>
|
||
<p>NAS 정적 서버에서 MP3·맵 JSON을 로컬 캐시로 내려받는다. 이미 있는 파일은 스킵(멱등성).</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>DownloadManager.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">DownloadManager</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private string</span> baseUrl = <span class="st">"http://whdwo798.synology.me/beatsaber"</span>;
|
||
|
||
<span class="ann">// static 프로퍼티: 인스턴스 없이 클래스 이름으로 접근 가능</span>
|
||
<span class="ann">// => 로 한 줄 표현 (expression-bodied property)</span>
|
||
<span class="kw">private static string</span> CacheRoot => Path.<span class="fn">Combine</span>(Application.temporaryCachePath, <span class="st">"beatsaber"</span>);
|
||
|
||
<span class="ann">// ── 공개 API ───────────────────────────────────────────────</span>
|
||
|
||
<span class="ann">// songs.json 전체 목록 가져오기</span>
|
||
<span class="kw">public void</span> <span class="fn">FetchSongsList</span>(<span class="ty">Action</span><<span class="ty">SongsList</span>> onSuccess, <span class="ty">Action</span><<span class="ty">string</span>> onError = <span class="kw">null</span>)
|
||
{
|
||
<span class="ann">// StartCoroutine: MonoBehaviour에서 코루틴 시작. void 메서드에서도 비동기 로직 실행 가능</span>
|
||
<span class="fn">StartCoroutine</span>(<span class="fn">GetText</span>($<span class="st">"{baseUrl}/songs.json"</span>, json => {
|
||
<span class="ty">SongsList</span> list = JsonUtility.<span class="fn">FromJson</span><<span class="ty">SongsList</span>>(json);
|
||
<span class="kw">if</span> (list == <span class="kw">null</span>) onError?.<span class="fn">Invoke</span>(<span class="st">"songs.json 파싱 실패"</span>);
|
||
<span class="kw">else</span> onSuccess?.<span class="fn">Invoke</span>(list);
|
||
}, onError));
|
||
}
|
||
|
||
<span class="ann">// 곡 다운로드 시작 (오디오 + 맵 JSON)</span>
|
||
<span class="kw">public void</span> <span class="fn">DownloadSong</span>(<span class="ty">SongInfo</span> song, <span class="ty">string</span> difficulty,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span><<span class="ty">string</span>> onError = <span class="kw">null</span>)
|
||
=> <span class="fn">StartCoroutine</span>(<span class="fn">DownloadSongCoroutine</span>(song, difficulty, onProgress, onComplete, onError));
|
||
|
||
<span class="kw">public void</span> <span class="fn">DeleteSong</span>(<span class="ty">string</span> songId)
|
||
{
|
||
<span class="ty">string</span> dir = <span class="fn">SongDir</span>(songId);
|
||
<span class="kw">if</span> (Directory.<span class="fn">Exists</span>(dir))
|
||
Directory.<span class="fn">Delete</span>(dir, recursive: <span class="kw">true</span>); <span class="ann">// 폴더 전체 삭제</span>
|
||
}
|
||
|
||
<span class="ann">// 오디오 파일 존재 = 곡이 다운로드됐다는 증거</span>
|
||
<span class="kw">public bool</span> <span class="fn">IsSongDownloaded</span>(<span class="ty">string</span> songId) => File.<span class="fn">Exists</span>(<span class="fn">AudioPath</span>(songId));
|
||
|
||
<span class="kw">public bool</span> <span class="fn">IsDifficultyDownloaded</span>(<span class="ty">SongInfo</span> song, <span class="ty">string</span> difficulty)
|
||
{
|
||
<span class="ty">string</span> path = <span class="fn">MapPath</span>(song, difficulty);
|
||
<span class="kw">return</span> path != <span class="kw">null</span> && File.<span class="fn">Exists</span>(path);
|
||
}
|
||
|
||
<span class="kw">public string</span> <span class="fn">AudioPath</span>(<span class="ty">string</span> songId) => Path.<span class="fn">Combine</span>(<span class="fn">SongDir</span>(songId), $<span class="st">"{songId}.mp3"</span>);
|
||
|
||
<span class="kw">public string</span> <span class="fn">MapPath</span>(<span class="ty">SongInfo</span> song, <span class="ty">string</span> difficulty)
|
||
{
|
||
<span class="ty">DifficultyInfo</span> info = song.difficulties.<span class="fn">Get</span>(difficulty);
|
||
<span class="kw">if</span> (info == <span class="kw">null</span> || string.<span class="fn">IsNullOrEmpty</span>(info.mapFile)) <span class="kw">return null</span>;
|
||
<span class="ty">string</span> fileName = Path.<span class="fn">GetFileName</span>(info.mapFile); <span class="ann">// "maps/Map_id_hard.json" → "Map_id_hard.json"</span>
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(fileName)) <span class="kw">return null</span>;
|
||
<span class="kw">return</span> Path.<span class="fn">Combine</span>(<span class="fn">SongDir</span>(song.id), fileName);
|
||
}
|
||
|
||
<span class="ann">// ── 내부 구현 ──────────────────────────────────────────────</span>
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">DownloadSongCoroutine</span>(<span class="ty">SongInfo</span> song, <span class="ty">string</span> difficulty,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="ann">// GetFullPath = 상대경로를 절대경로로 정규화. DownloadHandlerFile이 절대경로를 요구</span>
|
||
<span class="ty">string</span> songDir = Path.<span class="fn">GetFullPath</span>(<span class="fn">SongDir</span>(song.id));
|
||
Directory.<span class="fn">CreateDirectory</span>(songDir); <span class="ann">// 없으면 생성, 있으면 무시 (안전)</span>
|
||
|
||
<span class="ann">// 1단계: 오디오 (전체 진행률의 0~70%)</span>
|
||
<span class="ty">string</span> audioPath = Path.<span class="fn">Combine</span>(songDir, $<span class="st">"{song.id}.mp3"</span>);
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(audioPath)) <span class="ann">// 멱등성: 이미 있으면 스킵</span>
|
||
{
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
<span class="kw">yield return</span> <span class="fn">DownloadFile</span>(
|
||
$<span class="st">"{baseUrl}/{song.audioFile}"</span>, audioPath,
|
||
p => onProgress?.<span class="fn">Invoke</span>(p * <span class="nm">0.7f</span>), <span class="ann">// 0.0~0.7로 스케일</span>
|
||
err => { onError?.<span class="fn">Invoke</span>(err); failed = <span class="kw">true</span>; });
|
||
<span class="kw">if</span> (failed) <span class="kw">yield break</span>;
|
||
}
|
||
|
||
<span class="ann">// 2단계: 맵 JSON (전체 진행률의 70~100%)</span>
|
||
<span class="ty">DifficultyInfo</span> diffInfo = song.difficulties.<span class="fn">Get</span>(difficulty);
|
||
<span class="kw">if</span> (diffInfo == <span class="kw">null</span>) { onError?.<span class="fn">Invoke</span>($<span class="st">"난이도 '{difficulty}' 없음"</span>); <span class="kw">yield break</span>; }
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(diffInfo.mapFile))
|
||
{ onError?.<span class="fn">Invoke</span>(<span class="st">"맵 파일 정보 없음 — 곡을 다시 생성해주세요"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ty">string</span> mapPath = <span class="fn">MapPath</span>(song, difficulty);
|
||
<span class="kw">if</span> (mapPath != <span class="kw">null</span>) mapPath = Path.<span class="fn">GetFullPath</span>(mapPath);
|
||
<span class="kw">if</span> (mapPath == <span class="kw">null</span>) { onError?.<span class="fn">Invoke</span>(<span class="st">"맵 경로 계산 실패"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(mapPath))
|
||
{
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
<span class="kw">yield return</span> <span class="fn">DownloadFile</span>(
|
||
$<span class="st">"{baseUrl}/{diffInfo.mapFile}"</span>, mapPath,
|
||
p => onProgress?.<span class="fn">Invoke</span>(<span class="nm">0.7f</span> + p * <span class="nm">0.3f</span>), <span class="ann">// 0.7~1.0으로 스케일</span>
|
||
err => { onError?.<span class="fn">Invoke</span>(err); failed = <span class="kw">true</span>; });
|
||
<span class="kw">if</span> (failed) <span class="kw">yield break</span>;
|
||
}
|
||
|
||
onProgress?.<span class="fn">Invoke</span>(<span class="nm">1f</span>);
|
||
onComplete?.<span class="fn">Invoke</span>();
|
||
}
|
||
|
||
<span class="ann">// ── 단일 파일 다운로드 ─────────────────────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">DownloadFile</span>(<span class="ty">string</span> url, <span class="ty">string</span> savePath,
|
||
<span class="ty">Action</span><<span class="ty">float</span>> onProgress, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
<span class="ann">// DownloadHandlerFile = 응답 바이트를 메모리에 올리지 않고 바로 파일에 씀 (메모리 절약)</span>
|
||
req.downloadHandler = <span class="kw">new</span> <span class="ty">DownloadHandlerFile</span>(savePath);
|
||
req.<span class="fn">SendWebRequest</span>(); <span class="ann">// yield return 없이 시작 → 아래 while에서 진행률 폴링</span>
|
||
|
||
<span class="kw">while</span> (!req.isDone)
|
||
{
|
||
onProgress?.<span class="fn">Invoke</span>(req.downloadProgress); <span class="ann">// 0.0~1.0 다운로드 진행률</span>
|
||
<span class="kw">yield return null</span>; <span class="ann">// 한 프레임 대기</span>
|
||
}
|
||
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{
|
||
<span class="ann">// 실패 시 불완전 파일 삭제 → 다음 시도 때 "캐시 히트"로 잘못 판단하는 걸 방지</span>
|
||
<span class="kw">if</span> (File.<span class="fn">Exists</span>(savePath)) File.<span class="fn">Delete</span>(savePath);
|
||
onError?.<span class="fn">Invoke</span>($<span class="st">"다운로드 실패: {url} — {req.error}"</span>);
|
||
}
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">GetText</span>(<span class="ty">string</span> url, <span class="ty">Action</span><<span class="ty">string</span>> onSuccess, <span class="ty">Action</span><<span class="ty">string</span>> onError)
|
||
{
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
onError?.<span class="fn">Invoke</span>($<span class="st">"요청 실패: {url} — {req.error}"</span>);
|
||
<span class="kw">else</span>
|
||
onSuccess?.<span class="fn">Invoke</span>(req.downloadHandler.text);
|
||
}
|
||
|
||
<span class="ann">// 모든 경로 계산의 기준점. 이 값이 SongController의 CacheRoot와 동일해야 파일을 공유 가능</span>
|
||
<span class="kw">private static string</span> <span class="fn">SongDir</span>(<span class="ty">string</span> songId) => Path.<span class="fn">Combine</span>(CacheRoot, songId);
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ SongController.cs ══════════════════════ -->
|
||
<div id="p-songctrl" class="panel">
|
||
<div class="file-header">
|
||
<h1>SongController.cs</h1>
|
||
<p>Game 씬의 핵심 브릿지. GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고 카운트다운 후 큐브를 스폰한다.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>SongController.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">SongController</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// [SerializeField] = private이지만 Inspector에서 값 할당 가능</span>
|
||
<span class="ann">// Spawneable = VRBeatsKit의 큐브 프리팹 베이스 타입</span>
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Spawneable</span> cubePrefab;
|
||
<span class="ann">// GameEvent = VRBeatsKit의 ScriptableObject 기반 이벤트. 씬 완료 시 발동</span>
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">GameEvent</span> onLevelComplete;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Text</span> countdownText;
|
||
|
||
<span class="kw">private</span> <span class="ty">AudioManager</span> _audio; <span class="ann">// VRBeatsKit AudioManager: 실제 AudioSource 래핑</span>
|
||
|
||
<span class="ann">// static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다</span>
|
||
<span class="kw">private static string</span> CacheRoot => Path.<span class="fn">Combine</span>(Application.temporaryCachePath, <span class="st">"beatsaber"</span>);
|
||
|
||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||
{
|
||
<span class="ann">// FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출)</span>
|
||
_audio = <span class="fn">FindObjectOfType</span><<span class="ty">AudioManager</span>>();
|
||
<span class="fn">StartCoroutine</span>(<span class="fn">LoadAndPlay</span>());
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">LoadAndPlay</span>()
|
||
{
|
||
<span class="ty">SongInfo</span> song = <span class="ty">GameSession</span>.SelectedSong;
|
||
<span class="ty">string</span> diff = <span class="ty">GameSession</span>.SelectedDifficulty;
|
||
|
||
<span class="ann">// Game 씬을 직접 실행하면 null → 에러 후 종료</span>
|
||
<span class="kw">if</span> (song == <span class="kw">null</span> || string.<span class="fn">IsNullOrEmpty</span>(diff))
|
||
{ Debug.<span class="fn">LogError</span>(<span class="st">"[SongController] No song/difficulty selected"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// ── 오디오 로드 (비동기) ──────────────────────────────────</span>
|
||
<span class="ty">string</span> audioPath = Path.<span class="fn">Combine</span>(CacheRoot, song.id, song.id + <span class="st">".mp3"</span>);
|
||
<span class="ty">AudioClip</span> clip;
|
||
<span class="ann">// "file://" 프리픽스 = 로컬 파일을 URL 형식으로 접근</span>
|
||
<span class="ann">// GetAudioClip은 비동기 → yield return으로 완료까지 대기</span>
|
||
<span class="kw">using</span> (<span class="kw">var</span> req = <span class="ty">UnityWebRequestMultimedia</span>.<span class="fn">GetAudioClip</span>(<span class="st">"file://"</span> + audioPath, <span class="ty">AudioType</span>.MPEG))
|
||
{
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
<span class="kw">if</span> (req.result != <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{ Debug.<span class="fn">LogError</span>($<span class="st">"Audio load failed: {req.error}"</span>); <span class="kw">yield break</span>; }
|
||
<span class="ann">// GetContent = 요청에서 AudioClip 추출. using 블록 내에서 호출해야 함</span>
|
||
clip = <span class="ty">DownloadHandlerAudioClip</span>.<span class="fn">GetContent</span>(req);
|
||
}
|
||
|
||
<span class="ann">// ── 맵 로드 (동기) ────────────────────────────────────────</span>
|
||
<span class="ty">DifficultyInfo</span> diffInfo = song.difficulties.<span class="fn">Get</span>(diff);
|
||
<span class="kw">if</span> (diffInfo == <span class="kw">null</span>) { Debug.<span class="fn">LogError</span>($<span class="st">"Difficulty '{diff}' not found"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ty">string</span> mapPath = Path.<span class="fn">Combine</span>(CacheRoot, song.id, Path.<span class="fn">GetFileName</span>(diffInfo.mapFile));
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(mapPath)) { Debug.<span class="fn">LogError</span>($<span class="st">"Map missing: {mapPath}"</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// File.ReadAllText + JsonUtility.FromJson = 동기 파싱. 파일이 작으므로 문제없음</span>
|
||
<span class="ty">MapData</span> map = JsonUtility.<span class="fn">FromJson</span><<span class="ty">MapData</span>>(File.<span class="fn">ReadAllText</span>(mapPath));
|
||
<span class="kw">if</span> (map?.target == <span class="kw">null</span>) { Debug.<span class="fn">LogError</span>(<span class="st">"Map parse failed"</span>); <span class="kw">yield break</span>; }
|
||
<span class="ann">// time 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리</span>
|
||
map.target.<span class="fn">Sort</span>((a, b) => a.time.<span class="fn">CompareTo</span>(b.time));
|
||
|
||
<span class="ann">// ── 카운트다운 → 음악 시작 → 스폰 루프 ──────────────────</span>
|
||
<span class="kw">yield return</span> <span class="fn">StartCoroutine</span>(<span class="fn">Countdown</span>());
|
||
|
||
_audio.<span class="fn">PlayClip</span>(clip); <span class="ann">// 음악 재생 시작</span>
|
||
|
||
<span class="ann">// SpawnRoutine과 WaitForCompletion을 동시에 시작</span>
|
||
<span class="ann">// StartCoroutine() = 코루틴을 "백그라운드"로 실행하고 즉시 반환</span>
|
||
<span class="ann">// yield return StartCoroutine() = 완료까지 대기</span>
|
||
<span class="fn">StartCoroutine</span>(<span class="fn">SpawnRoutine</span>(map.target)); <span class="ann">// 병렬 실행 (대기 안 함)</span>
|
||
<span class="kw">yield return</span> <span class="fn">StartCoroutine</span>(<span class="fn">WaitForCompletion</span>(clip.length)); <span class="ann">// 곡 끝날 때까지 대기</span>
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">Countdown</span>()
|
||
{
|
||
<span class="kw">if</span> (countdownText == <span class="kw">null</span>) <span class="kw">yield break</span>;
|
||
countdownText.gameObject.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
|
||
<span class="ty">string</span>[] labels = { <span class="st">"3"</span>, <span class="st">"2"</span>, <span class="st">"1"</span>, <span class="st">"GO!"</span> };
|
||
<span class="kw">float</span>[] durations = { <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">0.6f</span> };
|
||
|
||
<span class="kw">for</span> (<span class="kw">int</span> i = <span class="nm">0</span>; i < labels.Length; i++)
|
||
{
|
||
countdownText.text = labels[i];
|
||
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(durations[i]);
|
||
}
|
||
countdownText.gameObject.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">SpawnRoutine</span>(<span class="ty">List</span><<span class="ty">NoteData</span>> notes)
|
||
{
|
||
<span class="kw">float</span> travelTime = <span class="ty">VR_BeatManager</span>.instance.GameSettings.TargetTravelTime; <span class="ann">// 현재 1.8초</span>
|
||
|
||
<span class="kw">foreach</span> (<span class="ty">NoteData</span> note <span class="kw">in</span> notes)
|
||
{
|
||
<span class="ann">// spawnAt = 이 노트를 스폰해야 하는 오디오 시간</span>
|
||
<span class="ann">// = 실제 칠 시간 - travelTime (큐브가 날아오는 데 걸리는 시간)</span>
|
||
<span class="ann">// Mathf.Max(0f, ...) = 음수 방지. 곡 시작 직후 노트는 즉시 스폰</span>
|
||
<span class="kw">float</span> spawnAt = Mathf.<span class="fn">Max</span>(<span class="nm">0f</span>, note.time - travelTime);
|
||
<span class="ann">// WaitUntil: 람다가 true가 될 때까지 매 프레임 체크</span>
|
||
<span class="kw">yield return new</span> <span class="ty">WaitUntil</span>(() => _audio.CurrentTime >= spawnAt);
|
||
<span class="fn">SpawnNote</span>(note);
|
||
}
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">SpawnNote</span>(<span class="ty">NoteData</span> note)
|
||
{
|
||
<span class="ann">// Beat Saber 4열 그리드 → 월드 X 좌표 선형 매핑</span>
|
||
<span class="ann">// 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭)</span>
|
||
<span class="kw">float</span> x = <span class="nm">-0.375f</span> + note.position * <span class="nm">0.25f</span>;
|
||
<span class="ann">// 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위)</span>
|
||
<span class="kw">float</span> y = <span class="nm">-0.333f</span> + note.lineLayer * <span class="nm">0.333f</span>;
|
||
|
||
<span class="ann">// ★ 핵심: travelTimeOverride 계산</span>
|
||
<span class="ann">// 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남</span>
|
||
<span class="ann">// 두 노트가 같은 TargetTravelTime(1.8s)으로 스폰되면 히트존 도착이 16ms 어긋남</span>
|
||
<span class="ann">// 해결: 스폰 시점의 실제 남은 시간(remaining)을 각 노트의 travelTime으로 사용</span>
|
||
<span class="ann">// → 노트A 스폰 시 remaining=1.800, 노트B 스폰 시 remaining=1.784</span>
|
||
<span class="ann">// → 각자 다른 speed로 발사되지만 같은 시각에 히트존 도착</span>
|
||
<span class="kw">float</span> remaining = note.time - _audio.CurrentTime;
|
||
<span class="kw">float</span> travelTime = Mathf.<span class="fn">Max</span>(<span class="nm">0.05f</span>, remaining); <span class="ann">// 최소 0.05s (0이 되면 즉시 소멸)</span>
|
||
|
||
<span class="kw">var</span> info = <span class="kw">new</span> <span class="ty">SpawnEventInfo</span>
|
||
{
|
||
position = <span class="kw">new</span> <span class="ty">Vector3</span>(x, y, <span class="nm">0f</span>),
|
||
<span class="ann">// colorType 0=빨강=왼손(Left), 1=파랑=오른손(Right)</span>
|
||
colorSide = note.colorType == <span class="nm">0</span> ? <span class="ty">ColorSide</span>.Left : <span class="ty">ColorSide</span>.Right,
|
||
hitDirection = <span class="fn">MapCutDirection</span>(note.cutDirection),
|
||
useSpark = <span class="kw">true</span>,
|
||
speed = <span class="nm">2f</span>,
|
||
travelTimeOverride = travelTime, <span class="ann">// VRBeatsKit이 이 값을 TargetTravelTime 대신 사용</span>
|
||
};
|
||
|
||
<span class="ty">VR_BeatManager</span>.instance.<span class="fn">Spawn</span>(cubePrefab, info);
|
||
}
|
||
|
||
<span class="ann">// ── cutDirection 조회 테이블 ──────────────────────────────</span>
|
||
<span class="ann">// Beat Saber 숫자(0-8) → VRBeatsKit Direction enum</span>
|
||
<span class="ann">// if-else 8개 대신 배열 인덱스로 O(1) 변환. static readonly = 앱 수명 동안 1번만 할당</span>
|
||
<span class="kw">private static readonly</span> <span class="ty">Direction</span>[] CutDirMap =
|
||
{
|
||
<span class="ty">Direction</span>.Up, <span class="ann">// 0</span>
|
||
<span class="ty">Direction</span>.Down, <span class="ann">// 1</span>
|
||
<span class="ty">Direction</span>.Left, <span class="ann">// 2</span>
|
||
<span class="ty">Direction</span>.Right, <span class="ann">// 3</span>
|
||
<span class="ty">Direction</span>.UpperLeft, <span class="ann">// 4</span>
|
||
<span class="ty">Direction</span>.UpperRight,<span class="ann">// 5</span>
|
||
<span class="ty">Direction</span>.LowerLeft, <span class="ann">// 6</span>
|
||
<span class="ty">Direction</span>.LowerRight,<span class="ann">// 7</span>
|
||
<span class="ty">Direction</span>.Center, <span class="ann">// 8 = Any (점 블록)</span>
|
||
};
|
||
|
||
<span class="ann">// 범위 체크 후 매핑. 범위 밖이면 Center(아무 방향)로 안전 처리</span>
|
||
<span class="kw">private static</span> <span class="ty">Direction</span> <span class="fn">MapCutDirection</span>(<span class="kw">int</span> cut)
|
||
=> (cut >= <span class="nm">0</span> && cut < CutDirMap.Length) ? CutDirMap[cut] : <span class="ty">Direction</span>.Center;
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">WaitForCompletion</span>(<span class="kw">float</span> clipLength)
|
||
{
|
||
<span class="ann">// 0.5초 여유: 마지막 노트 판정 후 ResultsPanel이 뜨기 전 잠깐 대기</span>
|
||
<span class="kw">yield return new</span> <span class="ty">WaitUntil</span>(() => _audio.CurrentTime >= clipLength - <span class="nm">0.5f</span>);
|
||
<span class="ann">// ?. = onLevelComplete가 Inspector에 연결 안 됐으면 아무것도 안 함</span>
|
||
onLevelComplete?.<span class="fn">Invoke</span>(); <span class="ann">// VRBeatsKit GameEvent 발동 → ResultsPanel 등 연결된 리스너 실행</span>
|
||
}
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ AudioManager.cs ══════════════════════ -->
|
||
<div id="p-audiomgr" class="panel">
|
||
<div class="file-header">
|
||
<h1>AudioManager.cs <span style="font-size:14px;font-weight:400;color:var(--mu)">(VRBeatsKit)</span></h1>
|
||
<p>VRBeatsKit 내장 오디오 관리자. 우리 코드에서 <code>PlayClip()</code>과 <code>CurrentTime</code>만 추가했다.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>VRBeatsKit/Scripts/Core/AudioManager.cs</span></div><pre>
|
||
<span class="kw">namespace</span> <span class="ty">VRBeats</span>
|
||
{
|
||
<span class="ann">// [RequireComponent] = 이 컴포넌트를 붙이려면 AudioSource도 반드시 있어야 함</span>
|
||
<span class="ann">// Inspector에서 AudioSource 없이 추가하려 하면 Unity가 자동으로 같이 추가</span>
|
||
[<span class="ty">RequireComponent</span>(<span class="kw">typeof</span>(<span class="ty">AudioSource</span>))]
|
||
<span class="kw">public class</span> <span class="ty">AudioManager</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">AudioMixerGroup</span> mixerGroup = <span class="kw">null</span>;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private float</span> fadeOutTime = <span class="nm">4.0f</span>; <span class="ann">// 피치 페이드 시간</span>
|
||
|
||
<span class="kw">private</span> <span class="ty">AudioSource</span> audioSource = <span class="kw">null</span>;
|
||
|
||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||
{
|
||
<span class="ann">// GetComponent: 같은 GameObject의 다른 컴포넌트를 참조</span>
|
||
audioSource = <span class="fn">GetComponent</span><<span class="ty">AudioSource</span>>();
|
||
<span class="ann">// AudioMixerGroup: 여러 AudioSource의 볼륨/이펙트를 믹서에서 일괄 제어</span>
|
||
audioSource.outputAudioMixerGroup = mixerGroup;
|
||
<span class="fn">ResetThisComponent</span>();
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">ResetThisComponent</span>()
|
||
{
|
||
<span class="fn">SetAudioMixerPitch</span>(<span class="nm">1.0f</span>); <span class="ann">// 피치 초기화</span>
|
||
<span class="ann">// CancelAllTweens: PlatinioTween 라이브러리 - 진행 중인 트윈 취소</span>
|
||
gameObject.<span class="fn">CancelAllTweens</span>();
|
||
}
|
||
|
||
<span class="ann">// BlendAudioMixerPitch: 피치를 from → to로 서서히 변환 (슬로우 다운 효과)</span>
|
||
<span class="ann">// BaseTween 반환 = 트윈 핸들. 호출부에서 취소하거나 완료 콜백을 추가할 수 있음</span>
|
||
<span class="kw">public</span> <span class="ty">BaseTween</span> <span class="fn">BlendAudioMixerPitch</span>(<span class="kw">float</span> from, <span class="kw">float</span> to)
|
||
{
|
||
<span class="ann">// PlatinioTween.ValueTween: 숫자값을 보간하는 트윈</span>
|
||
<span class="ann">// SetEase(EaseOutExpo): 처음엔 빠르고 끝에서 느려지는 곡선</span>
|
||
<span class="ann">// SetOnUpdateFloat: 매 프레임 보간된 값을 콜백으로 받음</span>
|
||
<span class="ann">// SetOwner: 해당 GameObject가 파괴되면 트윈도 자동 취소</span>
|
||
<span class="kw">return</span> <span class="ty">PlatinioTween</span>.instance.<span class="fn">ValueTween</span>(from, to, fadeOutTime)
|
||
.<span class="fn">SetEase</span>(<span class="ty">Ease</span>.EaseOutExpo)
|
||
.<span class="fn">SetOnUpdateFloat</span>(<span class="kw">delegate</span>(<span class="kw">float</span> v) {
|
||
<span class="kw">if</span> (audioSource != <span class="kw">null</span>) <span class="fn">SetAudioMixerPitch</span>(v);
|
||
})
|
||
.<span class="fn">SetOwner</span>(gameObject);
|
||
}
|
||
|
||
<span class="ann">// AudioMixer의 "Pitch" 노출 파라미터를 직접 설정</span>
|
||
<span class="ann">// AudioMixer에서 Pitch 파라미터를 Exposed Parameters에 등록해야 동작</span>
|
||
<span class="kw">public void</span> <span class="fn">SetAudioMixerPitch</span>(<span class="kw">float</span> value)
|
||
=> audioSource.outputAudioMixerGroup.audioMixer.<span class="fn">SetFloat</span>(<span class="st">"Pitch"</span>, value);
|
||
|
||
<span class="ann">// ★ 우리가 추가한 메서드 ─────────────────────────────────</span>
|
||
<span class="ann">// SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출</span>
|
||
<span class="kw">public void</span> <span class="fn">PlayClip</span>(<span class="ty">AudioClip</span> clip)
|
||
{
|
||
audioSource.clip = clip; <span class="ann">// 재생할 클립 교체</span>
|
||
audioSource.<span class="fn">Play</span>(); <span class="ann">// 즉시 재생 시작</span>
|
||
}
|
||
|
||
<span class="ann">// 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌다</span>
|
||
<span class="ann">// audioSource가 null이면 0 반환 (씬 초기화 중 안전)</span>
|
||
<span class="kw">public float</span> CurrentTime => audioSource != <span class="kw">null</span> ? audioSource.time : <span class="nm">0f</span>;
|
||
}
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ SongCreatorManager.cs ══════════════════════ -->
|
||
<div id="p-creator" class="panel">
|
||
<div class="file-header">
|
||
<h1>SongCreatorManager.cs</h1>
|
||
<p>Creator 씬의 UI 관리자. 파일 선택/URL 입력 → Beat Sage → NAS 업로드까지의 전체 플로우를 조율한다.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>SongCreatorManager.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">SongCreatorManager</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// Inspector에서 UI 컴포넌트들을 연결. 각 [Header]는 Inspector에서 구분선 역할</span>
|
||
[Header(<span class="st">"Audio Source"</span>)]
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Dropdown</span> audioDropdown;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Button</span> refreshBtn;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Text</span> inputPathHint; <span class="ann">// MP3 폴더 경로 안내 텍스트</span>
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_InputField</span> urlInput;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Button</span> urlDownloadBtn;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_InputField</span> titleInput, artistInput, bpmInput;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Toggle</span> toggleNormal, toggleHard, toggleExpert, toggleExpertPlus;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Button</span> generateButton, backButton;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">GameObject</span> progressGroup;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Text</span> statusText;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">Slider</span> progressSlider;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">BeatSageUploader</span> beatSageUploader;
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">NasPublisher</span> nasPublisher;
|
||
|
||
<span class="ann">// persistentDataPath = 앱 재시작 후에도 유지되는 폴더 (Documents/게임이름/)</span>
|
||
<span class="ann">// temporaryCachePath와 달리 OS가 임의로 삭제하지 않는다</span>
|
||
<span class="kw">private static string</span> InputPath => Path.<span class="fn">Combine</span>(Application.persistentDataPath, <span class="st">"input"</span>);
|
||
|
||
<span class="kw">private readonly</span> <span class="ty">List</span><<span class="ty">string</span>> audioFiles = <span class="kw">new</span>(); <span class="ann">// 드롭다운에 표시할 MP3 경로 목록</span>
|
||
<span class="kw">private string</span> _pendingFilePath; <span class="ann">// 파일 다이얼로그(STA 스레드)에서 메인 스레드로 경로 전달용</span>
|
||
|
||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||
{
|
||
Directory.<span class="fn">CreateDirectory</span>(InputPath); <span class="ann">// 없으면 생성</span>
|
||
<span class="kw">if</span> (inputPathHint != <span class="kw">null</span>) inputPathHint.text = $<span class="st">"Path: {InputPath}"</span>;
|
||
|
||
<span class="ann">// ?. = null 조건부 호출. Inspector에서 연결 안 된 버튼도 안전하게 처리</span>
|
||
refreshBtn?.<span class="ty">onClick</span>.<span class="fn">AddListener</span>(<span class="fn">RefreshAudioList</span>);
|
||
generateButton?.<span class="ty">onClick</span>.<span class="fn">AddListener</span>(<span class="fn">OnGenerateClicked</span>);
|
||
<span class="ann">// 람다: 클릭 시 menuSceneName 씬으로 이동</span>
|
||
backButton?.<span class="ty">onClick</span>.<span class="fn">AddListener</span>(() => <span class="ty">SceneManager</span>.<span class="fn">LoadScene</span>(menuSceneName));
|
||
urlDownloadBtn?.<span class="ty">onClick</span>.<span class="fn">AddListener</span>(<span class="fn">OnUrlDownloadClicked</span>);
|
||
<span class="kw">if</span> (progressGroup != <span class="kw">null</span>) progressGroup.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
<span class="fn">RefreshAudioList</span>();
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">Update</span>()
|
||
{
|
||
<span class="ann">// _pendingFilePath는 STA 스레드(파일 다이얼로그)에서 설정된다</span>
|
||
<span class="ann">// Unity API는 메인 스레드에서만 호출 가능 → Update()에서 처리</span>
|
||
<span class="kw">if</span> (_pendingFilePath != <span class="kw">null</span>)
|
||
{ <span class="fn">CopyToInput</span>(_pendingFilePath); _pendingFilePath = <span class="kw">null</span>; }
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">RefreshAudioList</span>()
|
||
{
|
||
audioFiles.<span class="fn">Clear</span>();
|
||
audioDropdown?.<span class="fn">ClearOptions</span>();
|
||
<span class="kw">var</span> options = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>>();
|
||
<span class="ann">// InputPath 폴더의 모든 .mp3 파일을 드롭다운에 추가</span>
|
||
<span class="kw">foreach</span> (<span class="ty">string</span> f <span class="kw">in</span> Directory.<span class="fn">GetFiles</span>(InputPath, <span class="st">"*.mp3"</span>))
|
||
{
|
||
audioFiles.<span class="fn">Add</span>(f);
|
||
options.<span class="fn">Add</span>(Path.<span class="fn">GetFileNameWithoutExtension</span>(f)); <span class="ann">// 확장자 없는 파일명만 표시</span>
|
||
}
|
||
<span class="kw">if</span> (options.Count == <span class="nm">0</span>) options.<span class="fn">Add</span>(<span class="st">"-- no .mp3 files --"</span>);
|
||
audioDropdown?.<span class="fn">AddOptions</span>(options);
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">OnGenerateClicked</span>()
|
||
{
|
||
<span class="ty">string</span> directUrl = urlInput != <span class="kw">null</span> ? urlInput.text.<span class="fn">Trim</span>() : <span class="st">""</span>;
|
||
<span class="kw">bool</span> hasUrl = !string.<span class="fn">IsNullOrEmpty</span>(directUrl);
|
||
<span class="kw">bool</span> hasFile = audioFiles.Count > <span class="nm">0</span>;
|
||
<span class="kw">if</span> (!hasUrl && !hasFile) { <span class="fn">SetStatus</span>(<span class="st">"No audio source."</span>); <span class="kw">return</span>; }
|
||
|
||
<span class="ann">// float.TryParse: 파싱 실패해도 예외 없음. out float = 결과값 수신</span>
|
||
float.<span class="fn">TryParse</span>(bpmInput?.text, <span class="kw">out float</span> bpmHint);
|
||
<span class="ann">// 현재 4개 난이도 전체를 항상 생성 (Toggle 값은 현재 미사용)</span>
|
||
<span class="kw">var</span> diffs = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>> { <span class="st">"normal"</span>, <span class="st">"hard"</span>, <span class="st">"expert"</span>, <span class="st">"expertplus"</span> };
|
||
|
||
<span class="kw">if</span> (hasUrl)
|
||
{
|
||
<span class="ann">// Uri.TryCreate: URL 형식이 올바른지 검증. 잘못된 URL이면 false</span>
|
||
<span class="kw">if</span> (!<span class="ty">Uri</span>.<span class="fn">TryCreate</span>(directUrl, <span class="ty">UriKind</span>.Absolute, <span class="kw">out var</span> uri) ||
|
||
(uri.Scheme != <span class="st">"http"</span> && uri.Scheme != <span class="st">"https"</span>))
|
||
{ <span class="fn">SetStatus</span>(<span class="st">"Invalid URL."</span>); <span class="kw">return</span>; }
|
||
<span class="fn">StartCoroutine</span>(<span class="fn">GenerateFlowFromUrl</span>(uri.AbsoluteUri, bpmHint, diffs));
|
||
}
|
||
<span class="kw">else</span>
|
||
{
|
||
<span class="ann">// audioDropdown.value = 현재 선택된 항목의 인덱스</span>
|
||
<span class="fn">StartCoroutine</span>(<span class="fn">GenerateFlow</span>(audioFiles[audioDropdown.value], bpmHint, diffs));
|
||
}
|
||
}
|
||
|
||
<span class="ann">// ── URL 모드 파이프라인 (오디오 업로드 없음) ──────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">GenerateFlowFromUrl</span>(<span class="ty">string</span> audioUrl, <span class="kw">float</span> bpm, <span class="ty">List</span><<span class="ty">string</span>> diffs)
|
||
{
|
||
<span class="fn">SetInteractable</span>(<span class="kw">false</span>); <span class="ann">// 진행 중 버튼 비활성화</span>
|
||
progressGroup.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
|
||
<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> maps = <span class="kw">null</span>;
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
|
||
<span class="ann">// 1~3단계: Beat Sage API (진행률 0~80%)</span>
|
||
<span class="kw">yield return</span> beatSageUploader.<span class="fn">UploadFromUrl</span>(
|
||
audioUrl, diffs, bpm,
|
||
onProgress: p => {
|
||
progressSlider.value = p * <span class="nm">0.8f</span>;
|
||
<span class="fn">SetStatus</span>($<span class="st">"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)"</span>);
|
||
},
|
||
onComplete: result => maps = result, <span class="ann">// 변환된 맵 수신</span>
|
||
onError: err => { <span class="fn">SetStatus</span>($<span class="st">"Error: {err}"</span>); failed = <span class="kw">true</span>; });
|
||
|
||
<span class="kw">if</span> (failed) { <span class="fn">SetInteractable</span>(<span class="kw">true</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ann">// SongInfo 조립: info.dat 메타데이터 + UI 입력 합산</span>
|
||
<span class="ty">SongInfo</span> song = <span class="fn">BuildSongInfo</span>(audioUrl, bpm, maps);
|
||
|
||
<span class="ann">// 4단계: NAS 업로드 (진행률 80~100%)</span>
|
||
<span class="ann">// audioPath=null → NAS에 오디오 업로드 스킵 (URL로 접근 가능하므로)</span>
|
||
<span class="kw">yield return</span> nasPublisher.<span class="fn">Publish</span>(
|
||
song, <span class="kw">null</span>, maps,
|
||
onProgress: p => {
|
||
progressSlider.value = <span class="nm">0.8f</span> + p * <span class="nm">0.2f</span>;
|
||
<span class="fn">SetStatus</span>($<span class="st">"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)"</span>);
|
||
},
|
||
onComplete: () => { progressSlider.value = <span class="nm">1f</span>; <span class="fn">SetStatus</span>($<span class="st">"Done! '{song.title}' created."</span>); },
|
||
onError: err => { <span class="fn">SetStatus</span>($<span class="st">"NAS upload failed: {err}"</span>); failed = <span class="kw">true</span>; });
|
||
|
||
<span class="fn">SetInteractable</span>(<span class="kw">true</span>);
|
||
}
|
||
|
||
<span class="ann">// ── 로컬 파일 모드 파이프라인 (오디오도 NAS에 업로드) ─────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">GenerateFlow</span>(<span class="ty">string</span> audioPath, <span class="kw">float</span> bpm, <span class="ty">List</span><<span class="ty">string</span>> diffs)
|
||
{
|
||
<span class="fn">SetInteractable</span>(<span class="kw">false</span>);
|
||
progressGroup.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> maps = <span class="kw">null</span>;
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
|
||
<span class="kw">yield return</span> beatSageUploader.<span class="fn">Upload</span>(audioPath, diffs, bpm,
|
||
onProgress: p => { progressSlider.value = p * <span class="nm">0.8f</span>;
|
||
<span class="fn">SetStatus</span>($<span class="st">"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)"</span>); },
|
||
onComplete: result => maps = result,
|
||
onError: err => { <span class="fn">SetStatus</span>($<span class="st">"Error: {err}"</span>); failed = <span class="kw">true</span>; });
|
||
<span class="kw">if</span> (failed) { <span class="fn">SetInteractable</span>(<span class="kw">true</span>); <span class="kw">yield break</span>; }
|
||
|
||
<span class="ty">SongInfo</span> song = <span class="fn">BuildSongInfo</span>(audioPath, bpm, maps);
|
||
|
||
<span class="ann">// audioPath를 전달 → NasPublisher가 MP3도 NAS에 업로드</span>
|
||
<span class="kw">yield return</span> nasPublisher.<span class="fn">Publish</span>(song, audioPath, maps,
|
||
onProgress: p => { progressSlider.value = <span class="nm">0.8f</span> + p * <span class="nm">0.2f</span>;
|
||
<span class="fn">SetStatus</span>($<span class="st">"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)"</span>); },
|
||
onComplete: () => { progressSlider.value = <span class="nm">1f</span>; <span class="fn">SetStatus</span>($<span class="st">"Done! '{song.title}' created."</span>); },
|
||
onError: err => { <span class="fn">SetStatus</span>($<span class="st">"NAS upload failed: {err}"</span>); failed = <span class="kw">true</span>; });
|
||
<span class="fn">SetInteractable</span>(<span class="kw">true</span>);
|
||
}
|
||
|
||
<span class="ann">// ── SongInfo 조립: info.dat 자동감지값 + UI 입력값 합산 ─────</span>
|
||
<span class="ann">// 우선순위: UI 입력 > info.dat > fallback(파일명/타임스탬프)</span>
|
||
<span class="kw">private</span> <span class="ty">SongInfo</span> <span class="fn">BuildSongInfo</span>(<span class="ty">string</span> audioPath, <span class="kw">float</span> fallbackBpm,
|
||
<span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">List</span><<span class="ty">NoteData</span>>> maps)
|
||
{
|
||
<span class="kw">var</span> meta = beatSageUploader?.LastMetadata; <span class="ann">// info.dat에서 읽은 자동 감지값</span>
|
||
<span class="kw">string</span> uiTitle = titleInput?.text.<span class="fn">Trim</span>() ?? <span class="st">""</span>;
|
||
<span class="kw">string</span> uiArtist = artistInput?.text.<span class="fn">Trim</span>() ?? <span class="st">""</span>;
|
||
float.<span class="fn">TryParse</span>(bpmInput?.text, <span class="kw">out float</span> uiBpm);
|
||
|
||
<span class="ann">// 3단계 우선순위 선택: UI입력 → info.dat → 빈 문자열</span>
|
||
<span class="kw">string</span> title = !string.<span class="fn">IsNullOrEmpty</span>(uiTitle) ? uiTitle : (meta?.title ?? <span class="st">""</span>);
|
||
<span class="kw">string</span> artist = !string.<span class="fn">IsNullOrEmpty</span>(uiArtist) ? uiArtist : (meta?.artist ?? <span class="st">""</span>);
|
||
<span class="ann">// BPM도 3단계: info.dat(자동감지) → UI입력 → fallback</span>
|
||
<span class="kw">float</span> bpm = (meta != <span class="kw">null</span> && meta.bpm > <span class="nm">0</span>) ? meta.bpm : (uiBpm > <span class="nm">0</span> ? uiBpm : fallbackBpm);
|
||
|
||
<span class="ann">// 제목이 없으면 파일명 → 그것도 없으면 타임스탬프 기반 id</span>
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(title) && !string.<span class="fn">IsNullOrEmpty</span>(audioPath))
|
||
title = Path.<span class="fn">GetFileNameWithoutExtension</span>(audioPath);
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(title))
|
||
title = $<span class="st">"song_{DateTime.Now:yyyyMMdd_HHmmss}"</span>;
|
||
|
||
<span class="ann">// id = 소문자+언더스코어. 파일 경로/URL에 안전하게 사용하기 위해</span>
|
||
<span class="kw">string</span> id = title.<span class="fn">ToLower</span>().<span class="fn">Replace</span>(<span class="st">" "</span>, <span class="st">"_"</span>);
|
||
|
||
<span class="ann">// maps에 있는 난이도만 DifficultyMap에 채움</span>
|
||
<span class="kw">var</span> diffMap = <span class="kw">new</span> <span class="ty">DifficultyMap</span>();
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> kv <span class="kw">in</span> maps)
|
||
{
|
||
<span class="kw">var</span> info = <span class="kw">new</span> <span class="ty">DifficultyInfo</span> { noteCount = kv.Value.Count };
|
||
<span class="kw">switch</span> (kv.Key)
|
||
{
|
||
<span class="kw">case</span> <span class="st">"normal"</span>: diffMap.normal = info; <span class="kw">break</span>;
|
||
<span class="kw">case</span> <span class="st">"hard"</span>: diffMap.hard = info; <span class="kw">break</span>;
|
||
<span class="kw">case</span> <span class="st">"expert"</span>: diffMap.expert = info; <span class="kw">break</span>;
|
||
<span class="kw">case</span> <span class="st">"expertplus"</span>: diffMap.expertplus = info; <span class="kw">break</span>;
|
||
}
|
||
}
|
||
<span class="kw">return new</span> <span class="ty">SongInfo</span>
|
||
{
|
||
id = id, title = title, artist = artist, bpm = bpm,
|
||
audioFile = $<span class="st">"music/{id}.mp3"</span>, <span class="ann">// NAS 상대경로</span>
|
||
difficulties = diffMap,
|
||
addedAt = <span class="ty">DateTime</span>.Now.<span class="fn">ToString</span>(<span class="st">"yyyy-MM-dd"</span>),
|
||
};
|
||
}
|
||
|
||
<span class="ann">// ── 파일 선택 다이얼로그 (플랫폼별 분기) ────────────────────</span>
|
||
<span class="kw">private void</span> <span class="fn">OnFilePickerClicked</span>()
|
||
{
|
||
<span class="ann">// #if = 조건부 컴파일. 해당 플랫폼에서만 코드가 포함됨</span>
|
||
<span class="ann">// UNITY_EDITOR = 에디터에서 실행 시</span>
|
||
#<span class="kw">if</span> UNITY_EDITOR
|
||
<span class="ann">// EditorUtility.OpenFilePanel = 에디터 전용 파일 선택 다이얼로그</span>
|
||
<span class="ty">string</span> path = <span class="ty">UnityEditor</span>.<span class="ty">EditorUtility</span>.<span class="fn">OpenFilePanel</span>(<span class="st">"Select audio file"</span>, <span class="st">""</span>, <span class="st">"mp3"</span>);
|
||
<span class="kw">if</span> (!string.<span class="fn">IsNullOrEmpty</span>(path)) <span class="fn">CopyToInput</span>(path);
|
||
#<span class="kw">elif</span> UNITY_STANDALONE_WIN
|
||
<span class="ann">// Windows Forms 파일 다이얼로그는 STA(Single Thread Apartment) 스레드에서 실행해야 함</span>
|
||
<span class="ann">// Unity 메인 스레드는 MTA → 별도 Thread 생성 필요</span>
|
||
<span class="kw">var</span> t = <span class="kw">new</span> <span class="ty">Thread</span>(() => {
|
||
<span class="kw">var</span> dlg = <span class="kw">new</span> <span class="ty">System.Windows.Forms.OpenFileDialog</span> { Filter = <span class="st">"MP3|*.mp3"</span> };
|
||
<span class="kw">if</span> (dlg.<span class="fn">ShowDialog</span>() == <span class="ty">System.Windows.Forms.DialogResult</span>.OK)
|
||
_pendingFilePath = dlg.FileName; <span class="ann">// 메인 스레드 Update()가 이 값을 처리</span>
|
||
});
|
||
t.<span class="fn">SetApartmentState</span>(<span class="ty">ApartmentState</span>.STA); <span class="ann">// Windows Forms 요구사항</span>
|
||
t.<span class="fn">Start</span>();
|
||
#<span class="kw">else</span>
|
||
<span class="ann">// Quest/Android: 파일 다이얼로그 없음 → ADB로 복사하는 방법 안내</span>
|
||
<span class="fn">SetAddStatus</span>($<span class="st">"Copy file via ADB:\n{InputPath}"</span>);
|
||
#<span class="kw">endif</span>
|
||
}
|
||
|
||
<span class="ann">// ── URL에서 직접 MP3 다운로드 ────────────────────────────────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">DownloadFromUrl</span>(<span class="ty">string</span> url)
|
||
{
|
||
<span class="fn">SetAddStatus</span>(<span class="st">"Downloading..."</span>);
|
||
<span class="kw">if</span> (urlDownloadBtn != <span class="kw">null</span>) urlDownloadBtn.interactable = <span class="kw">false</span>;
|
||
|
||
<span class="ann">// URL 경로에서 파일명 추출. 없거나 .mp3가 아니면 "download.mp3"</span>
|
||
<span class="ty">string</span> fileName;
|
||
<span class="kw">try</span> { <span class="ty">string</span> uriPath = <span class="kw">new</span> <span class="ty">Uri</span>(url).AbsolutePath;
|
||
fileName = Path.<span class="fn">GetFileName</span>(uriPath);
|
||
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(fileName) || !fileName.<span class="fn">EndsWith</span>(<span class="st">".mp3"</span>, StringComparison.OrdinalIgnoreCase))
|
||
fileName = <span class="st">"download.mp3"</span>; }
|
||
<span class="kw">catch</span> { fileName = <span class="st">"download.mp3"</span>; }
|
||
|
||
<span class="ty">string</span> savePath = Path.<span class="fn">GetFullPath</span>(Path.<span class="fn">Combine</span>(InputPath, fileName));
|
||
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
|
||
req.downloadHandler = <span class="kw">new</span> <span class="ty">DownloadHandlerFile</span>(savePath); <span class="ann">// 파일로 직접 저장</span>
|
||
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
|
||
|
||
<span class="kw">if</span> (urlDownloadBtn != <span class="kw">null</span>) urlDownloadBtn.interactable = <span class="kw">true</span>;
|
||
|
||
<span class="kw">if</span> (req.result == <span class="ty">UnityWebRequest</span>.Result.Success)
|
||
{
|
||
<span class="fn">RefreshAudioList</span>();
|
||
<span class="ann">// 방금 다운받은 파일을 드롭다운에서 자동 선택</span>
|
||
<span class="kw">int</span> idx = audioFiles.<span class="fn">FindIndex</span>(
|
||
f => Path.<span class="fn">GetFileNameWithoutExtension</span>(f) == Path.<span class="fn">GetFileNameWithoutExtension</span>(fileName));
|
||
<span class="kw">if</span> (idx >= <span class="nm">0</span> && audioDropdown != <span class="kw">null</span>) audioDropdown.value = idx;
|
||
<span class="fn">SetAddStatus</span>($<span class="st">"Downloaded: {fileName}"</span>);
|
||
}
|
||
<span class="kw">else</span>
|
||
{
|
||
<span class="kw">if</span> (File.<span class="fn">Exists</span>(savePath)) File.<span class="fn">Delete</span>(savePath); <span class="ann">// 실패 시 불완전 파일 삭제</span>
|
||
<span class="fn">SetAddStatus</span>($<span class="st">"Download failed: {req.error}"</span>);
|
||
}
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">SetInteractable</span>(<span class="kw">bool</span> value)
|
||
{
|
||
<span class="ann">// 진행 중 모든 입력 비활성화 → 완료 후 다시 활성화</span>
|
||
<span class="kw">if</span> (generateButton != <span class="kw">null</span>) generateButton.interactable = value;
|
||
<span class="kw">if</span> (audioDropdown != <span class="kw">null</span>) audioDropdown.interactable = value;
|
||
<span class="kw">if</span> (refreshBtn != <span class="kw">null</span>) refreshBtn.interactable = value;
|
||
<span class="kw">if</span> (urlDownloadBtn != <span class="kw">null</span>) urlDownloadBtn.interactable = value;
|
||
}
|
||
<span class="kw">private void</span> <span class="fn">SetStatus</span> (<span class="ty">string</span> msg) { <span class="kw">if</span> (statusText != <span class="kw">null</span>) statusText.text = msg; }
|
||
<span class="kw">private void</span> <span class="fn">SetAddStatus</span>(<span class="ty">string</span> msg) { <span class="kw">if</span> (addStatusText != <span class="kw">null</span>) addStatusText.text = msg; }
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
|
||
<!-- ══════════════════════ SongDetailPanel.cs ══════════════════════ -->
|
||
<div id="p-detail" class="panel">
|
||
<div class="file-header">
|
||
<h1>SongDetailPanel.cs</h1>
|
||
<p>곡 카드 클릭 시 열리는 상세 패널. 난이도 선택 → 다운로드 → 플레이까지 처리한다.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>SongDetailPanel.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">SongDetailPanel</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// diffSlots: 난이도 4개의 (키, 버튼접근자) 쌍을 배열로 관리</span>
|
||
<span class="ann">// Func<SongDetailPanel, Button> = 이 패널을 받아서 해당 버튼을 반환하는 함수</span>
|
||
<span class="ann">// 이렇게 하면 foreach 하나로 4개 난이도를 동일하게 처리 가능 (코드 중복 제거)</span>
|
||
<span class="kw">private readonly</span> (<span class="ty">string</span> key, <span class="ty">Func</span><<span class="ty">SongDetailPanel</span>, <span class="ty">Button</span>> btn)[] diffSlots =
|
||
{
|
||
(<span class="st">"normal"</span>, p => p.btnNormal),
|
||
(<span class="st">"hard"</span>, p => p.btnHard),
|
||
(<span class="st">"expert"</span>, p => p.btnExpert),
|
||
(<span class="st">"expertplus"</span>, p => p.btnExpertPlus),
|
||
};
|
||
|
||
<span class="ann">// ── 외부에서 호출하는 진입점 ──────────────────────────────</span>
|
||
<span class="kw">public void</span> <span class="fn">Show</span>(<span class="ty">SongInfo</span> song, <span class="ty">DownloadManager</span> dm, <span class="ty">SongSelectManager</span> sm)
|
||
{
|
||
currentSong = song; downloadManager = dm; selectManager = sm;
|
||
selectedDifficulty = <span class="kw">null</span>; <span class="ann">// 새 곡을 열면 난이도 선택 초기화</span>
|
||
|
||
titleText.text = song.title;
|
||
artistText.text = song.artist;
|
||
<span class="ann">// duration이 있으면 "BPM 120 | 3:45" 형식, 없으면 BPM만</span>
|
||
infoText.text = song.duration > <span class="nm">0</span>
|
||
? $<span class="st">"BPM {Mathf.RoundToInt(song.bpm)} | {FormatDuration(song.duration)}"</span>
|
||
: $<span class="st">"BPM {Mathf.RoundToInt(song.bpm)}"</span>;
|
||
<span class="fn">RefreshUI</span>();
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">RefreshUI</span>()
|
||
{
|
||
<span class="kw">bool</span> downloaded = <span class="ty">SongLibrary</span>.Instance.<span class="fn">IsSongDownloaded</span>(currentSong.id);
|
||
|
||
<span class="ann">// 튜플 분해: (key, getBtn) = diffSlots의 각 원소</span>
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> (key, getBtn) <span class="kw">in</span> diffSlots)
|
||
{
|
||
<span class="ty">Button</span> btn = getBtn(<span class="kw">this</span>);
|
||
<span class="kw">bool</span> exists = currentSong.difficulties.<span class="fn">Get</span>(key) != <span class="kw">null</span>;
|
||
|
||
<span class="ann">// 다운로드됐고 이 난이도가 존재할 때만 활성화</span>
|
||
btn.interactable = downloaded && exists;
|
||
btn.onClick.<span class="fn">RemoveAllListeners</span>(); <span class="ann">// 이전 곡의 리스너 제거 (메모리 누수 방지)</span>
|
||
|
||
<span class="kw">if</span> (downloaded && exists)
|
||
{
|
||
<span class="ann">// string captured = key: foreach 클로저 버그 방지 (각 람다가 자신의 key를 캡처)</span>
|
||
<span class="ty">string</span> captured = key;
|
||
btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">SelectDifficulty</span>(captured));
|
||
}
|
||
}
|
||
|
||
<span class="fn">UpdateDiffColors</span>();
|
||
downloadButton.gameObject.<span class="fn">SetActive</span>(!downloaded); <span class="ann">// 미다운로드 시 다운로드 버튼</span>
|
||
deleteButton.gameObject.<span class="fn">SetActive</span>(downloaded); <span class="ann">// 다운로드 완료 시 삭제 버튼</span>
|
||
playButton.interactable = downloaded && selectedDifficulty != <span class="kw">null</span>;
|
||
|
||
<span class="ann">// 리스너를 항상 초기화 후 재등록 → 이전 곡의 리스너가 남는 버그 방지</span>
|
||
downloadButton.onClick.<span class="fn">RemoveAllListeners</span>(); downloadButton.onClick.<span class="fn">AddListener</span>(<span class="fn">OnDownloadClicked</span>);
|
||
deleteButton.onClick.<span class="fn">RemoveAllListeners</span>(); deleteButton.onClick.<span class="fn">AddListener</span>(<span class="fn">OnDeleteClicked</span>);
|
||
playButton.onClick.<span class="fn">RemoveAllListeners</span>(); playButton.onClick.<span class="fn">AddListener</span>(<span class="fn">OnPlayClicked</span>);
|
||
closeButton?.onClick.<span class="fn">RemoveAllListeners</span>();
|
||
closeButton?.onClick.<span class="fn">AddListener</span>(() => gameObject.<span class="fn">SetActive</span>(<span class="kw">false</span>));
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">UpdateDiffColors</span>()
|
||
{
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> (key, getBtn) <span class="kw">in</span> diffSlots)
|
||
{
|
||
<span class="ty">Button</span> btn = getBtn(<span class="kw">this</span>);
|
||
<span class="kw">bool</span> selected = key == selectedDifficulty;
|
||
<span class="ann">// targetGraphic is Image = 타입 패턴 매칭. 캐스트 성공 시 img에 저장</span>
|
||
<span class="kw">if</span> (btn.targetGraphic <span class="kw">is</span> <span class="ty">Image</span> img)
|
||
img.color = selected ? SelectedColor : DeselectedImgColor;
|
||
}
|
||
}
|
||
|
||
<span class="ann">// ── 다운로드: 존재하는 모든 난이도를 순서대로 다운로드 ──────</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">DownloadAllCoroutine</span>()
|
||
{
|
||
<span class="ann">// 이 곡에 존재하는 난이도 목록 수집</span>
|
||
<span class="kw">var</span> diffs = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>>();
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> (key, _) <span class="kw">in</span> diffSlots)
|
||
<span class="kw">if</span> (currentSong.difficulties.<span class="fn">Get</span>(key) != <span class="kw">null</span>) diffs.<span class="fn">Add</span>(key);
|
||
<span class="kw">if</span> (diffs.Count == <span class="nm">0</span>) <span class="kw">yield break</span>;
|
||
|
||
<span class="fn">SetInteractable</span>(<span class="kw">false</span>);
|
||
progressGroup.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
|
||
<span class="kw">int</span> totalSteps = diffs.Count;
|
||
<span class="kw">int</span> doneSteps = <span class="nm">0</span>;
|
||
<span class="kw">bool</span> failed = <span class="kw">false</span>;
|
||
|
||
<span class="ann">// 난이도 하나씩 순서대로 다운로드 (병렬 X)</span>
|
||
<span class="kw">foreach</span> (<span class="ty">string</span> diff <span class="kw">in</span> diffs)
|
||
{
|
||
<span class="kw">bool</span> stepDone = <span class="kw">false</span>;
|
||
downloadManager.<span class="fn">DownloadSong</span>(
|
||
currentSong, diff,
|
||
onProgress: p => {
|
||
<span class="ann">// 전체 진행률 = (완료된 단계 + 현재 단계 진행률) / 총 단계 수</span>
|
||
<span class="kw">float</span> overall = (doneSteps + p) / totalSteps;
|
||
progressSlider.value = overall;
|
||
progressText.text = $<span class="st">"{diffs[Mathf.Min(doneSteps, diffs.Count-1)].ToUpper()} {(int)(overall*100)}%"</span>;
|
||
},
|
||
onComplete: () => {
|
||
<span class="ty">SongLibrary</span>.Instance.<span class="fn">MarkDownloaded</span>(currentSong.id, diff); <span class="ann">// 라이브러리에 기록</span>
|
||
doneSteps++;
|
||
stepDone = <span class="kw">true</span>;
|
||
},
|
||
onError: err => { Debug.<span class="fn">LogError</span>(err); failed = <span class="kw">true</span>; stepDone = <span class="kw">true</span>; });
|
||
|
||
<span class="ann">// stepDone이 true가 될 때까지 대기 (콜백 기반 → 코루틴 완료 감지)</span>
|
||
<span class="kw">yield return new</span> <span class="ty">WaitUntil</span>(() => stepDone);
|
||
<span class="kw">if</span> (failed) <span class="kw">break</span>;
|
||
}
|
||
|
||
<span class="fn">SetInteractable</span>(<span class="kw">true</span>);
|
||
progressGroup.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
selectManager.<span class="fn">RefreshCards</span>(); <span class="ann">// 카드 목록에 OWNED 배지 반영</span>
|
||
<span class="fn">RefreshUI</span>();
|
||
}
|
||
|
||
<span class="ann">// ── 플레이: GameSession에 선택 저장 후 씬 이동 ──────────────</span>
|
||
<span class="kw">private void</span> <span class="fn">OnPlayClicked</span>()
|
||
{
|
||
<span class="ty">GameSession</span>.SelectedSong = currentSong;
|
||
<span class="ty">GameSession</span>.SelectedDifficulty = selectedDifficulty;
|
||
<span class="ty">SceneManager</span>.<span class="fn">LoadScene</span>(gameSceneName); <span class="ann">// "Game" 씬으로 이동</span>
|
||
}
|
||
|
||
<span class="ann">// "3:45" 형식 포맷터. D2 = 최소 2자리, 부족하면 앞에 0 채움 (45 → "45", 5 → "05")</span>
|
||
<span class="kw">private static string</span> <span class="fn">FormatDuration</span>(<span class="kw">int</span> seconds)
|
||
=> $<span class="st">"{seconds / 60}:{seconds % 60:D2}"</span>;
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ SongSelectManager.cs ══════════════════════ -->
|
||
<div id="p-selectmgr" class="panel">
|
||
<div class="file-header">
|
||
<h1>SongSelectManager.cs</h1>
|
||
<p>NAS에서 곡 목록을 불러오고 코드로 카드 UI를 동적 생성한다. 오프라인 캐시 폴백 포함.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>SongSelectManager.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">SongSelectManager</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// 탭 색상: 활성=불투명(0.45), 비활성=반투명(0.12)</span>
|
||
<span class="kw">private static readonly</span> <span class="ty">Color</span> TabActive = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">0.45f</span>);
|
||
<span class="kw">private static readonly</span> <span class="ty">Color</span> TabInactive = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">0.12f</span>);
|
||
|
||
<span class="ann">// persistentDataPath: 앱 재시작 후에도 살아남는 폴더. songs_cache.json 저장에 사용</span>
|
||
<span class="kw">private static string</span> CachePath => Path.<span class="fn">Combine</span>(Application.persistentDataPath, <span class="st">"songs_cache.json"</span>);
|
||
|
||
<span class="kw">private void</span> <span class="fn">Start</span>()
|
||
{
|
||
<span class="ann">// Resources.Load: Resources 폴더에서 에셋 로드. 없으면 null</span>
|
||
_cardFont = Resources.<span class="fn">Load</span><<span class="ty">TMP_FontAsset</span>>(<span class="st">"Fonts & Materials/NanumGothic SDF"</span>);
|
||
<span class="kw">if</span> (_cardFont == <span class="kw">null</span>) <span class="ann">// 폴백: 한글 폰트 없으면 기본 폰트</span>
|
||
_cardFont = Resources.<span class="fn">Load</span><<span class="ty">TMP_FontAsset</span>>(<span class="st">"Fonts & Materials/LiberationSans SDF"</span>);
|
||
|
||
tabAllBtn .onClick.<span class="fn">AddListener</span>(() => <span class="fn">SwitchTab</span>(<span class="kw">false</span>));
|
||
tabOwnedBtn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">SwitchTab</span>(<span class="kw">true</span>));
|
||
detailPanel.gameObject.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
<span class="fn">SetTabVisual</span>(<span class="kw">false</span>);
|
||
<span class="fn">FetchSongs</span>();
|
||
}
|
||
|
||
<span class="ann">// ── 서버에서 목록 가져오기 (오프라인 폴백 포함) ─────────────</span>
|
||
<span class="kw">private void</span> <span class="fn">FetchSongs</span>()
|
||
{
|
||
loadingOverlay.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
errorOverlay.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
|
||
downloadManager.<span class="fn">FetchSongsList</span>(
|
||
onSuccess: list => {
|
||
allSongs = list.songs;
|
||
<span class="fn">SaveCache</span>(list); <span class="ann">// 성공 시 로컬에 캐시 저장</span>
|
||
<span class="ann">// 파일시스템 검증: SongLibrary가 알고 있는 다운로드 상태를 실제 파일 존재와 비교</span>
|
||
<span class="ty">SongLibrary</span>.Instance.<span class="fn">ValidateWithFileSystem</span>(downloadManager, list.songs);
|
||
loadingOverlay.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
<span class="fn">RefreshCards</span>();
|
||
},
|
||
onError: _ => {
|
||
<span class="ann">// 네트워크 실패 → 캐시가 있으면 오프라인 목록 표시</span>
|
||
<span class="ty">SongsList</span> cached = <span class="fn">LoadCache</span>();
|
||
loadingOverlay.<span class="fn">SetActive</span>(<span class="kw">false</span>);
|
||
<span class="kw">if</span> (cached != <span class="kw">null</span>) { allSongs = cached.songs; <span class="fn">RefreshCards</span>(); }
|
||
<span class="kw">else</span> { errorOverlay.<span class="fn">SetActive</span>(<span class="kw">true</span>);
|
||
errorText.text = <span class="st">"Failed to connect to server\nPlease check your internet connection"</span>; }
|
||
});
|
||
}
|
||
|
||
<span class="kw">public void</span> <span class="fn">RefreshCards</span>()
|
||
{
|
||
<span class="ann">// DestroyImmediate: 기존 카드 즉시 삭제. Destroy()는 프레임 끝에 삭제 → 레이아웃 계산 오류 발생</span>
|
||
<span class="kw">for</span> (<span class="kw">int</span> i = cardContainer.childCount - <span class="nm">1</span>; i >= <span class="nm">0</span>; i--)
|
||
<span class="ty">DestroyImmediate</span>(cardContainer.<span class="fn">GetChild</span>(i).gameObject);
|
||
|
||
<span class="ann">// showingOwned=true면 다운로드된 곡만 필터링</span>
|
||
<span class="ty">List</span><<span class="ty">SongInfo</span>> songs = showingOwned
|
||
? allSongs.<span class="fn">FindAll</span>(s => <span class="ty">SongLibrary</span>.Instance.<span class="fn">IsSongDownloaded</span>(s.id))
|
||
: allSongs;
|
||
|
||
<span class="kw">foreach</span> (<span class="ty">SongInfo</span> song <span class="kw">in</span> songs) <span class="fn">SpawnCard</span>(song);
|
||
|
||
<span class="ann">// 레이아웃 재계산 순서가 중요: ForceRebuild → ForceUpdateCanvases</span>
|
||
<span class="ann">// 순서가 바뀌면 카드 크기 계산이 틀려서 겹침 현상 발생</span>
|
||
<span class="ty">LayoutRebuilder</span>.<span class="fn">ForceRebuildLayoutImmediate</span>(cardContainer);
|
||
<span class="ty">Canvas</span>.<span class="fn">ForceUpdateCanvases</span>();
|
||
}
|
||
|
||
<span class="ann">// ── 카드 1개 동적 생성 ────────────────────────────────────</span>
|
||
<span class="kw">private void</span> <span class="fn">SpawnCard</span>(<span class="ty">SongInfo</span> song)
|
||
{
|
||
<span class="kw">bool</span> downloaded = <span class="ty">SongLibrary</span>.Instance.<span class="fn">IsSongDownloaded</span>(song.id);
|
||
|
||
<span class="ann">// new GameObject + AddComponent: 프리팹 없이 코드로 UI 생성</span>
|
||
<span class="kw">var</span> card = <span class="kw">new</span> <span class="ty">GameObject</span>(song.title);
|
||
card.transform.<span class="fn">SetParent</span>(cardContainer, <span class="kw">false</span>); <span class="ann">// false = 로컬 트랜스폼 유지</span>
|
||
|
||
<span class="ann">// LayoutElement: VerticalLayoutGroup이 이 카드의 높이/너비를 어떻게 배분할지 제어</span>
|
||
<span class="ann">// preferredHeight=13f → 13 units 고정 높이. flexibleWidth=1 → 가로는 꽉 채움</span>
|
||
<span class="kw">var</span> le = card.<span class="fn">AddComponent</span><<span class="ty">LayoutElement</span>>();
|
||
le.preferredHeight = <span class="nm">13f</span>; le.flexibleWidth = <span class="nm">1f</span>;
|
||
|
||
<span class="kw">var</span> bg = card.<span class="fn">AddComponent</span><<span class="ty">Image</span>>();
|
||
bg.color = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">0.06f</span>); <span class="ann">// 반투명 흰색 배경</span>
|
||
|
||
<span class="kw">var</span> btn = card.<span class="fn">AddComponent</span><<span class="ty">Button</span>>();
|
||
btn.targetGraphic = bg;
|
||
<span class="kw">var</span> bc = btn.colors;
|
||
bc.normalColor = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">1f</span>, <span class="nm">0.06f</span>);
|
||
bc.highlightedColor = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">0.4f</span>, <span class="nm">0.75f</span>, <span class="nm">1f</span>, <span class="nm">0.25f</span>); <span class="ann">// 호버 시 파란 하이라이트</span>
|
||
bc.pressedColor = <span class="kw">new</span> <span class="ty">Color</span>(<span class="nm">0.3f</span>, <span class="nm">0.6f</span>, <span class="nm">0.9f</span>, <span class="nm">0.45f</span>);
|
||
bc.fadeDuration = <span class="nm">0.1f</span>; <span class="ann">// 색상 전환 속도</span>
|
||
btn.colors = bc;
|
||
|
||
<span class="ann">// 마퀴 구조: RectMask2D 안에서 텍스트가 스크롤</span>
|
||
<span class="ann">// TitleMask(RectMask2D) → Title(TMP_Text + MarqueeText)</span>
|
||
<span class="kw">var</span> titleMask = <span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"TitleMask"</span>);
|
||
titleMask.transform.<span class="fn">SetParent</span>(card.transform, <span class="kw">false</span>);
|
||
<span class="kw">var</span> tmr = titleMask.<span class="fn">AddComponent</span><<span class="ty">RectTransform</span>>();
|
||
<span class="ann">// anchorMin/Max: 0~1 범위로 부모 대비 위치 지정. (0,0.5)~(1,1) = 상반부 전체</span>
|
||
tmr.anchorMin = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">0f</span>, <span class="nm">0.5f</span>); tmr.anchorMax = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">1f</span>, <span class="nm">1f</span>);
|
||
<span class="ann">// offsetMin/Max: anchor 기준 픽셀 오프셋. downloaded면 배지 공간(-20f) 확보</span>
|
||
tmr.offsetMin = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">5f</span>, <span class="nm">0f</span>); tmr.offsetMax = <span class="kw">new</span> <span class="ty">Vector2</span>(downloaded ? <span class="nm">-20f</span> : <span class="nm">-3f</span>, <span class="nm">0f</span>);
|
||
titleMask.<span class="fn">AddComponent</span><<span class="ty">RectMask2D</span>>(); <span class="ann">// 이 컨테이너 영역 밖을 잘라냄</span>
|
||
|
||
<span class="kw">var</span> titleGO = <span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"Title"</span>);
|
||
titleGO.transform.<span class="fn">SetParent</span>(titleMask.transform, <span class="kw">false</span>);
|
||
<span class="kw">var</span> tr = titleGO.<span class="fn">AddComponent</span><<span class="ty">RectTransform</span>>();
|
||
<span class="ann">// pivot=(0,0.5): 텍스트 왼쪽 중앙을 기준점으로. MarqueeText가 x를 음수로 이동시켜 스크롤</span>
|
||
tr.anchorMin = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">0f</span>,<span class="nm">0f</span>); tr.anchorMax = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">0f</span>,<span class="nm">1f</span>); tr.pivot = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">0f</span>,<span class="nm">0.5f</span>);
|
||
tr.anchoredPosition = <span class="ty">Vector2</span>.zero; tr.sizeDelta = <span class="kw">new</span> <span class="ty">Vector2</span>(<span class="nm">500f</span>, <span class="nm">0f</span>); <span class="ann">// 넓게 설정 → 마스크 밖으로 나옴</span>
|
||
<span class="kw">var</span> tTmp = titleGO.<span class="fn">AddComponent</span><<span class="ty">TextMeshProUGUI</span>>();
|
||
<span class="kw">if</span> (_cardFont != <span class="kw">null</span>) tTmp.font = _cardFont;
|
||
tTmp.text = song.title; tTmp.fontSize = <span class="nm">5f</span>; tTmp.color = <span class="ty">Color</span>.white;
|
||
tTmp.overflowMode = <span class="ty">TextOverflowModes</span>.Overflow; <span class="ann">// 영역 넘어도 잘리지 않음 (RectMask2D가 처리)</span>
|
||
tTmp.enableWordWrapping = <span class="kw">false</span>; <span class="ann">// 줄바꿈 금지 → 한 줄로</span>
|
||
titleGO.<span class="fn">AddComponent</span><<span class="ty">MarqueeText</span>>(); <span class="ann">// 텍스트가 컨테이너보다 길면 자동 스크롤</span>
|
||
|
||
<span class="ann">// ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사</span>
|
||
<span class="ty">SongInfo</span> captured = song;
|
||
btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardClicked</span>(captured));
|
||
}
|
||
|
||
<span class="ann">// ── 캐시 저장/로드 ───────────────────────────────────────</span>
|
||
<span class="kw">private static void</span> <span class="fn">SaveCache</span>(<span class="ty">SongsList</span> list)
|
||
{
|
||
<span class="ann">// 빈 try-catch: 파일 쓰기 실패(권한 등)해도 앱 크래시 방지</span>
|
||
<span class="kw">try</span> { File.<span class="fn">WriteAllText</span>(CachePath, JsonUtility.<span class="fn">ToJson</span>(list, <span class="kw">true</span>)); } <span class="kw">catch</span> { }
|
||
}
|
||
<span class="kw">private static</span> <span class="ty">SongsList</span> <span class="fn">LoadCache</span>()
|
||
{
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(CachePath)) <span class="kw">return null</span>;
|
||
<span class="kw">try</span> { <span class="kw">return</span> JsonUtility.<span class="fn">FromJson</span><<span class="ty">SongsList</span>>(File.<span class="fn">ReadAllText</span>(CachePath)); } <span class="kw">catch</span> { <span class="kw">return null</span>; }
|
||
}
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ SongLibrary.cs ══════════════════════ -->
|
||
<div id="p-songlibrary" class="panel">
|
||
<div class="file-header">
|
||
<h1>SongLibrary.cs</h1>
|
||
<p>다운로드 상태를 <code>song_library.json</code>에 영구 저장하는 싱글턴. 파일시스템과 동기화 기능 포함.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>SongLibrary.cs</span></div><pre>
|
||
<span class="ann">// 싱글턴(Singleton) 패턴: 앱 전체에서 인스턴스 1개만 존재를 보장</span>
|
||
<span class="kw">public class</span> <span class="ty">SongLibrary</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// { get; private set; } = 외부 읽기 가능, 내부만 쓰기 가능</span>
|
||
<span class="kw">public static</span> <span class="ty">SongLibrary</span> Instance { <span class="kw">get</span>; <span class="kw">private set</span>; }
|
||
|
||
<span class="kw">private const string</span> FileName = <span class="st">"song_library.json"</span>;
|
||
<span class="kw">private static string</span> SavePath => Path.<span class="fn">Combine</span>(Application.persistentDataPath, FileName);
|
||
|
||
<span class="kw">private</span> <span class="ty">LibraryData</span> _data = <span class="kw">new</span> <span class="ty">LibraryData</span>(); <span class="ann">// 메모리 내 상태</span>
|
||
|
||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||
{
|
||
<span class="ann">// 이미 Instance가 있으면(씬 재로드 등) 이 객체 파괴 → 1개 보장</span>
|
||
<span class="kw">if</span> (Instance != <span class="kw">null</span>) { <span class="fn">Destroy</span>(gameObject); <span class="kw">return</span>; }
|
||
Instance = <span class="kw">this</span>;
|
||
<span class="ann">// DontDestroyOnLoad: 씬 전환 시 파괴되지 않음. 앱 수명 동안 유지</span>
|
||
<span class="ty">DontDestroyOnLoad</span>(gameObject);
|
||
<span class="fn">Load</span>(); <span class="ann">// 앱 시작 시 저장된 데이터 로드</span>
|
||
}
|
||
|
||
<span class="ann">// MarkDownloaded: 다운로드 완료 후 라이브러리에 기록</span>
|
||
<span class="kw">public void</span> <span class="fn">MarkDownloaded</span>(<span class="ty">string</span> songId, <span class="ty">string</span> difficulty)
|
||
{
|
||
<span class="ty">LibraryEntry</span> entry = <span class="fn">GetOrCreate</span>(songId);
|
||
<span class="ann">// Contains: 이미 있으면 중복 추가 방지</span>
|
||
<span class="kw">if</span> (!entry.difficulties.<span class="fn">Contains</span>(difficulty))
|
||
entry.difficulties.<span class="fn">Add</span>(difficulty);
|
||
<span class="ann">// "o" 형식 = ISO 8601 (2026-05-22T13:00:00Z). UTC 기준으로 시간대 무관하게 비교 가능</span>
|
||
entry.lastAccessedAt = <span class="ty">DateTime</span>.UtcNow.<span class="fn">ToString</span>(<span class="st">"o"</span>);
|
||
<span class="fn">Save</span>(); <span class="ann">// 즉시 파일에 저장 (앱 크래시해도 데이터 보존)</span>
|
||
}
|
||
|
||
<span class="kw">public void</span> <span class="fn">MarkSongRemoved</span>(<span class="ty">string</span> songId)
|
||
{
|
||
<span class="ann">// RemoveAll: 조건 람다를 만족하는 모든 항목 제거</span>
|
||
_data.entries.<span class="fn">RemoveAll</span>(e => e.songId == songId);
|
||
<span class="fn">Save</span>();
|
||
}
|
||
|
||
<span class="kw">public bool</span> <span class="fn">IsSongDownloaded</span>(<span class="ty">string</span> songId) => <span class="fn">Find</span>(songId) != <span class="kw">null</span>;
|
||
|
||
<span class="ann">// ?. = null 조건부 접근. Find가 null이면 false 반환 (NullReferenceException 없음)</span>
|
||
<span class="ann">// ?? false = Contains 결과가 null일 수 없지만 ?. 때문에 nullable → ?? 로 기본값</span>
|
||
<span class="kw">public bool</span> <span class="fn">IsDifficultyDownloaded</span>(<span class="ty">string</span> songId, <span class="ty">string</span> difficulty)
|
||
=> <span class="fn">Find</span>(songId)?.difficulties.<span class="fn">Contains</span>(difficulty) ?? <span class="kw">false</span>;
|
||
|
||
<span class="ann">// ── 파일시스템 검증: 라이브러리 기록 vs 실제 파일 동기화 ─────</span>
|
||
<span class="ann">// 실제 파일이 없는데 라이브러리엔 있는 경우 (외부 삭제 등) 제거</span>
|
||
<span class="kw">public void</span> <span class="fn">ValidateWithFileSystem</span>(<span class="ty">DownloadManager</span> dm, <span class="ty">List</span><<span class="ty">SongInfo</span>> songs)
|
||
{
|
||
<span class="kw">bool</span> dirty = <span class="kw">false</span>;
|
||
<span class="kw">foreach</span> (<span class="ty">SongInfo</span> song <span class="kw">in</span> songs)
|
||
{
|
||
<span class="ty">LibraryEntry</span> entry = <span class="fn">Find</span>(song.id);
|
||
<span class="kw">if</span> (entry == <span class="kw">null</span>) <span class="kw">continue</span>;
|
||
|
||
<span class="ann">// MP3 파일 없으면 이 곡 전체 제거</span>
|
||
<span class="kw">if</span> (!dm.<span class="fn">IsSongDownloaded</span>(song.id))
|
||
{ _data.entries.<span class="fn">Remove</span>(entry); dirty = <span class="kw">true</span>; <span class="kw">continue</span>; }
|
||
|
||
<span class="ann">// 각 난이도 파일 없는 것 제거</span>
|
||
entry.difficulties.<span class="fn">RemoveAll</span>(d => !dm.<span class="fn">IsDifficultyDownloaded</span>(song, d));
|
||
<span class="kw">if</span> (entry.difficulties.Count == <span class="nm">0</span>)
|
||
{ _data.entries.<span class="fn">Remove</span>(entry); dirty = <span class="kw">true</span>; }
|
||
}
|
||
<span class="ann">// dirty 플래그: 변경이 있을 때만 파일 저장 (불필요한 IO 방지)</span>
|
||
<span class="kw">if</span> (dirty) <span class="fn">Save</span>();
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">LibraryEntry</span> <span class="fn">Find</span>(<span class="ty">string</span> songId)
|
||
=> _data.entries.<span class="fn">Find</span>(e => e.songId == songId); <span class="ann">// 없으면 null</span>
|
||
|
||
<span class="kw">private</span> <span class="ty">LibraryEntry</span> <span class="fn">GetOrCreate</span>(<span class="ty">string</span> songId)
|
||
{
|
||
<span class="ty">LibraryEntry</span> entry = <span class="fn">Find</span>(songId);
|
||
<span class="kw">if</span> (entry != <span class="kw">null</span>) <span class="kw">return</span> entry;
|
||
entry = <span class="kw">new</span> <span class="ty">LibraryEntry</span> { songId = songId };
|
||
_data.entries.<span class="fn">Add</span>(entry);
|
||
<span class="kw">return</span> entry;
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">Load</span>()
|
||
{
|
||
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(SavePath)) <span class="kw">return</span>;
|
||
<span class="kw">try</span>
|
||
{
|
||
<span class="ty">string</span> json = File.<span class="fn">ReadAllText</span>(SavePath);
|
||
<span class="ann">// ?? new: 파일이 있지만 파싱 실패하면 빈 데이터로 초기화</span>
|
||
_data = JsonUtility.<span class="fn">FromJson</span><<span class="ty">LibraryData</span>>(json) ?? <span class="kw">new</span> <span class="ty">LibraryData</span>();
|
||
}
|
||
<span class="kw">catch</span> (<span class="ty">Exception</span> e)
|
||
{
|
||
Debug.<span class="fn">LogWarning</span>($<span class="st">"[SongLibrary] 로드 실패, 초기화: {e.Message}"</span>);
|
||
_data = <span class="kw">new</span> <span class="ty">LibraryData</span>();
|
||
}
|
||
}
|
||
|
||
<span class="kw">private void</span> <span class="fn">Save</span>() => File.<span class="fn">WriteAllText</span>(SavePath, JsonUtility.<span class="fn">ToJson</span>(_data, <span class="kw">true</span>));
|
||
}
|
||
|
||
[<span class="ty">Serializable</span>] <span class="kw">public class</span> <span class="ty">LibraryData</span>
|
||
{
|
||
<span class="kw">public</span> <span class="ty">List</span><<span class="ty">LibraryEntry</span>> entries = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">LibraryEntry</span>>();
|
||
}
|
||
|
||
[<span class="ty">Serializable</span>] <span class="kw">public class</span> <span class="ty">LibraryEntry</span>
|
||
{
|
||
<span class="kw">public string</span> songId;
|
||
<span class="kw">public</span> <span class="ty">List</span><<span class="ty">string</span>> difficulties = <span class="kw">new</span> <span class="ty">List</span><<span class="ty">string</span>>(); <span class="ann">// ["normal","hard"]</span>
|
||
<span class="kw">public string</span> lastAccessedAt; <span class="ann">// ISO 8601 UTC 시각</span>
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ MarqueeText.cs ══════════════════════ -->
|
||
<div id="p-marquee" class="panel">
|
||
<div class="file-header">
|
||
<h1>MarqueeText.cs</h1>
|
||
<p>텍스트가 컨테이너보다 길 때 자동으로 옆으로 스크롤하는 컴포넌트. 정지 → 왼쪽 스크롤 → 정지 → 반복.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>MarqueeText.cs</span></div><pre>
|
||
<span class="ann">// [RequireComponent] = TMP_Text 없이 이 컴포넌트만 붙이면 Unity가 자동으로 TMP_Text도 추가</span>
|
||
[<span class="ty">RequireComponent</span>(<span class="kw">typeof</span>(<span class="ty">TMP_Text</span>))]
|
||
<span class="kw">public class</span> <span class="ty">MarqueeText</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="kw">public float</span> speed = <span class="nm">35f</span>; <span class="ann">// 스크롤 속도 (units/초)</span>
|
||
<span class="kw">public float</span> pauseStart = <span class="nm">1.5f</span>; <span class="ann">// 시작 위치에서 대기 시간</span>
|
||
<span class="kw">public float</span> pauseEnd = <span class="nm">0.6f</span>; <span class="ann">// 끝 위치에서 대기 시간 후 처음으로 돌아감</span>
|
||
|
||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||
{
|
||
_label = <span class="fn">GetComponent</span><<span class="ty">TMP_Text</span>>();
|
||
_rect = <span class="fn">GetComponent</span><<span class="ty">RectTransform</span>>();
|
||
}
|
||
|
||
<span class="ann">// Start가 IEnumerator를 반환하면 Unity가 자동으로 코루틴으로 실행</span>
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">Start</span>()
|
||
{
|
||
<span class="ann">// yield return null: 1프레임 대기. 레이아웃이 완전히 계산된 후 크기를 읽어야 정확하다</span>
|
||
<span class="ann">// Awake/Start에서 바로 읽으면 0이 나올 수 있음</span>
|
||
<span class="kw">yield return null</span>;
|
||
|
||
_label.<span class="fn">ForceMeshUpdate</span>(); <span class="ann">// TMP 메시 강제 갱신 → preferredWidth가 정확한 값 반환</span>
|
||
<span class="kw">float</span> textW = _label.preferredWidth; <span class="ann">// 텍스트 실제 너비</span>
|
||
<span class="kw">float</span> containerW = ((<span class="ty">RectTransform</span>)transform.parent).rect.width; <span class="ann">// 부모(TitleMask) 너비</span>
|
||
<span class="kw">float</span> dist = textW - containerW; <span class="ann">// 스크롤해야 할 거리</span>
|
||
|
||
<span class="ann">// 텍스트가 컨테이너보다 1 unit 이상 길 때만 마퀴 시작. 짧으면 정지 상태 유지</span>
|
||
<span class="kw">if</span> (dist > <span class="nm">1f</span>) <span class="fn">StartCoroutine</span>(<span class="fn">ScrollLoop</span>(dist));
|
||
}
|
||
|
||
<span class="kw">private</span> <span class="ty">IEnumerator</span> <span class="fn">ScrollLoop</span>(<span class="kw">float</span> dist)
|
||
{
|
||
<span class="kw">while</span> (<span class="kw">true</span>) <span class="ann">// 무한 반복</span>
|
||
{
|
||
<span class="fn">SetX</span>(<span class="nm">0f</span>); <span class="ann">// 시작 위치(왼쪽 끝) 복귀</span>
|
||
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(pauseStart); <span class="ann">// 1.5초 정지</span>
|
||
|
||
<span class="kw">float</span> x = <span class="nm">0f</span>;
|
||
<span class="ann">// x가 -dist에 도달할 때까지 매 프레임 이동 (왼쪽으로 스크롤)</span>
|
||
<span class="kw">while</span> (x > -dist)
|
||
{
|
||
<span class="ann">// MoveTowards: 현재값에서 목표값 방향으로 최대 speed*dt 만큼 이동</span>
|
||
<span class="ann">// 단순 x -= speed*dt 와 달리 목표값을 절대 넘지 않음</span>
|
||
x = Mathf.<span class="fn">MoveTowards</span>(x, -dist, speed * <span class="ty">Time</span>.deltaTime);
|
||
<span class="fn">SetX</span>(x);
|
||
<span class="kw">yield return null</span>; <span class="ann">// 매 프레임 실행</span>
|
||
}
|
||
|
||
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(pauseEnd); <span class="ann">// 0.6초 정지 후 처음으로</span>
|
||
}
|
||
}
|
||
|
||
<span class="ann">// anchoredPosition의 y는 유지하고 x만 변경</span>
|
||
<span class="kw">private void</span> <span class="fn">SetX</span>(<span class="kw">float</span> x) =>
|
||
_rect.anchoredPosition = <span class="kw">new</span> <span class="ty">Vector2</span>(x, _rect.anchoredPosition.y);
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ DesktopUIMode.cs ══════════════════════ -->
|
||
<div id="p-desktop" class="panel">
|
||
<div class="file-header">
|
||
<h1>DesktopUIMode.cs</h1>
|
||
<p>에디터/PC 전용 헬퍼. Quest 빌드에선 <strong>컴파일 자체가 안 된다</strong>. 마우스 클릭 활성화 + ESC 뒤로가기.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>DesktopUIMode.cs</span></div><pre>
|
||
<span class="ann">// #if !UNITY_ANDROID || UNITY_EDITOR</span>
|
||
<span class="ann">// = "안드로이드가 아니거나(PC/Mac) 에디터면" → Quest 실제 빌드에서는 이 파일 전체가 제외됨</span>
|
||
<span class="kw">public class</span> <span class="ty">DesktopUIMode</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// 이하 코드는 에디터 또는 PC 빌드에서만 포함</span>
|
||
#<span class="kw">if</span> !UNITY_ANDROID || UNITY_EDITOR
|
||
|
||
<span class="ann">// [RuntimeInitializeOnLoadMethod] = 씬 시작 시 Unity가 자동 호출하는 static 메서드</span>
|
||
<span class="ann">// AfterSceneLoad = 씬 오브젝트 Awake/Start 후에 실행</span>
|
||
<span class="ann">// → 씬에 직접 배치하지 않아도 자동으로 생성됨</span>
|
||
[<span class="ty">RuntimeInitializeOnLoadMethod</span>(<span class="ty">RuntimeInitializeLoadType</span>.AfterSceneLoad)]
|
||
<span class="kw">private static void</span> <span class="fn">AutoCreate</span>()
|
||
{
|
||
<span class="kw">if</span> (<span class="fn">FindObjectOfType</span><<span class="ty">DesktopUIMode</span>>() != <span class="kw">null</span>) <span class="kw">return</span>; <span class="ann">// 이미 있으면 스킵</span>
|
||
<span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"[DesktopUIMode]"</span>).<span class="fn">AddComponent</span><<span class="ty">DesktopUIMode</span>>();
|
||
}
|
||
|
||
<span class="ann">// 씬별 뒤로가기 맵: ESC 누르면 이 씬으로 이동</span>
|
||
<span class="kw">private static readonly</span> <span class="ty">Dictionary</span><<span class="ty">string</span>, <span class="ty">string</span>> BackMap = <span class="kw">new</span>()
|
||
{
|
||
{ <span class="st">"SongSelect"</span>, <span class="st">"Menu"</span> }, { <span class="st">"SongCreator"</span>, <span class="st">"Menu"</span> },
|
||
{ <span class="st">"Game"</span>, <span class="st">"SongSelect"</span> },
|
||
};
|
||
|
||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||
{
|
||
<span class="ann">// FindObjectsByType: FindObjectsOfType의 최신 버전. 정렬 없음 = 더 빠름</span>
|
||
<span class="kw">if</span> (<span class="fn">FindObjectsByType</span><<span class="ty">DesktopUIMode</span>>(<span class="ty">FindObjectsSortMode</span>.None).Length > <span class="nm">1</span>)
|
||
{ <span class="fn">Destroy</span>(gameObject); <span class="kw">return</span>; } <span class="ann">// 중복 방지</span>
|
||
|
||
<span class="ty">DontDestroyOnLoad</span>(gameObject);
|
||
<span class="ann">// 이벤트 구독: 씬 로드될 때마다 OnSceneLoaded 호출</span>
|
||
<span class="ty">SceneManager</span>.sceneLoaded += <span class="fn">OnSceneLoaded</span>;
|
||
<span class="fn">PatchCanvases</span>();
|
||
}
|
||
|
||
<span class="ann">// OnDestroy에서 이벤트 구독 해제 → 메모리 누수 방지</span>
|
||
<span class="kw">private void</span> <span class="fn">OnDestroy</span>() => <span class="ty">SceneManager</span>.sceneLoaded -= <span class="fn">OnSceneLoaded</span>;
|
||
|
||
<span class="ann">// 씬 로드 직후 1프레임 대기 후 패치 (새 씬 오브젝트가 모두 Awake 된 후)</span>
|
||
<span class="kw">private void</span> <span class="fn">OnSceneLoaded</span>(<span class="ty">Scene</span> s, <span class="ty">LoadSceneMode</span> m) => <span class="fn">StartCoroutine</span>(<span class="fn">PatchAfterFrame</span>());
|
||
<span class="kw">private</span> <span class="ty">System.Collections.IEnumerator</span> <span class="fn">PatchAfterFrame</span>()
|
||
{ <span class="kw">yield return null</span>; <span class="fn">PatchCanvases</span>(); }
|
||
|
||
<span class="kw">private void</span> <span class="fn">Update</span>()
|
||
{
|
||
<span class="fn">RefreshCanvasCameras</span>();
|
||
<span class="ann">// Keyboard.current?. = Input System. ?. = 키보드 없으면 null 안전</span>
|
||
<span class="ann">// wasPressedThisFrame = 이번 프레임에 처음 눌렸을 때만 true</span>
|
||
<span class="kw">if</span> (<span class="ty">Keyboard</span>.current?.escapeKey.wasPressedThisFrame == <span class="kw">true</span>) <span class="fn">GoBack</span>();
|
||
}
|
||
|
||
<span class="kw">private static void</span> <span class="fn">PatchCanvases</span>()
|
||
{
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> canvas <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">Canvas</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||
{
|
||
<span class="kw">if</span> (canvas.renderMode != <span class="ty">RenderMode</span>.WorldSpace) <span class="kw">continue</span>;
|
||
<span class="ann">// VRBeatsKit Canvas에는 TrackedDeviceGraphicRaycaster가 붙어있음</span>
|
||
<span class="ann">// XR Raycaster는 PC에서 마우스 클릭 안 됨 → 일반 GraphicRaycaster로 교체</span>
|
||
<span class="kw">var</span> tracked = canvas.<span class="fn">GetComponent</span>(<span class="st">"TrackedDeviceGraphicRaycaster"</span>);
|
||
<span class="kw">if</span> (tracked != <span class="kw">null</span>)
|
||
{
|
||
<span class="ty">DestroyImmediate</span>(tracked); <span class="ann">// 즉시 삭제 (다음 줄에서 null 체크)</span>
|
||
<span class="kw">if</span> (canvas.<span class="fn">GetComponent</span><<span class="ty">GraphicRaycaster</span>>() == <span class="kw">null</span>)
|
||
canvas.gameObject.<span class="fn">AddComponent</span><<span class="ty">GraphicRaycaster</span>>();
|
||
}
|
||
}
|
||
<span class="fn">RemoveDuplicateAudioListeners</span>(); <span class="ann">// AudioListener 중복 시 경고 방지</span>
|
||
<span class="fn">RefreshCanvasCameras</span>();
|
||
}
|
||
|
||
<span class="kw">private static void</span> <span class="fn">RefreshCanvasCameras</span>()
|
||
{
|
||
<span class="ann">// WorldSpace Canvas는 worldCamera가 설정돼야 화면에 올바르게 렌더링됨</span>
|
||
<span class="ty">Camera</span> cam = <span class="ty">Camera</span>.main;
|
||
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="ann">// main 카메라 없으면 활성 카메라 중 첫 번째</span>
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> c <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">Camera</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||
<span class="kw">if</span> (c.enabled && c.gameObject.scene.name != <span class="st">"DontDestroyOnLoad"</span>) { cam = c; <span class="kw">break</span>; }
|
||
cam ??= <span class="fn">FindObjectOfType</span><<span class="ty">Camera</span>>(); <span class="ann">// 최후의 수단</span>
|
||
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="kw">return</span>;
|
||
|
||
<span class="kw">foreach</span> (<span class="kw">var</span> canvas <span class="kw">in</span> <span class="fn">FindObjectsByType</span><<span class="ty">Canvas</span>>(<span class="ty">FindObjectsSortMode</span>.None))
|
||
<span class="kw">if</span> (canvas.renderMode == <span class="ty">RenderMode</span>.WorldSpace && canvas.worldCamera != cam)
|
||
canvas.worldCamera = cam;
|
||
}
|
||
|
||
<span class="ann">// TryGetValue: 키가 없으면 false 반환 (KeyNotFoundException 없음)</span>
|
||
<span class="kw">private static void</span> <span class="fn">GoBack</span>()
|
||
{
|
||
<span class="kw">if</span> (BackMap.<span class="fn">TryGetValue</span>(<span class="ty">SceneManager</span>.<span class="fn">GetActiveScene</span>().name, <span class="kw">out</span> <span class="ty">string</span> target))
|
||
<span class="ty">SceneManager</span>.<span class="fn">LoadScene</span>(target);
|
||
}
|
||
#<span class="kw">endif</span>
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════ XRSimulatorLoader.cs ══════════════════════ -->
|
||
<div id="p-xrloader" class="panel">
|
||
<div class="file-header">
|
||
<h1>XRSimulatorLoader.cs</h1>
|
||
<p>에디터/PC에서 XR Interaction Simulator 프리팹을 자동으로 생성해주는 단순 로더.</p>
|
||
</div>
|
||
<div class="cw"><div class="ch"><span>XRSimulatorLoader.cs</span></div><pre>
|
||
<span class="kw">public class</span> <span class="ty">XRSimulatorLoader</span> : <span class="ty">MonoBehaviour</span>
|
||
{
|
||
<span class="ann">// Inspector에서 XR Interaction Simulator 프리팹을 연결</span>
|
||
<span class="ann">// 경로: Assets/Samples/XR Interaction Toolkit/버전/XR Interaction Simulator/</span>
|
||
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">GameObject</span> simulatorPrefab;
|
||
|
||
<span class="kw">private void</span> <span class="fn">Awake</span>()
|
||
{
|
||
<span class="ann">// 에디터 또는 PC 빌드에서만 실행. Quest Android 빌드에선 이 블록이 제외됨</span>
|
||
#<span class="kw">if</span> !UNITY_ANDROID || UNITY_EDITOR
|
||
<span class="kw">if</span> (simulatorPrefab != <span class="kw">null</span>)
|
||
<span class="fn">Instantiate</span>(simulatorPrefab); <span class="ann">// 씬에 프리팹 인스턴스 생성</span>
|
||
<span class="kw">else</span>
|
||
Debug.<span class="fn">LogWarning</span>(<span class="st">"[XRSimulatorLoader] simulatorPrefab is not assigned."</span>);
|
||
#<span class="kw">endif</span>
|
||
}
|
||
<span class="ann">// 조작 방법 (에디터 실행 시):</span>
|
||
<span class="ann">// 우클릭 드래그 = 머리 회전 | G + 마우스 = 오른쪽 컨트롤러 | Shift+G = 왼쪽</span>
|
||
<span class="ann">// Space = 트리거 (UI 클릭)</span>
|
||
}
|
||
</pre></div>
|
||
</div>
|
||
|
||
</div><!-- /main -->
|
||
|
||
<script>
|
||
function show(id) {
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('p-' + id).classList.add('active');
|
||
event.currentTarget.classList.add('active');
|
||
document.getElementById('main').scrollTop = 0;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|