Files
BeatSaber/annotated_code.html
T
whdwo798 58838f0acb docs: add annotated code & code review HTML documents
학습용 HTML 2개 추가 — 전체 15개 스크립트 줄 단위 주석본(annotated_code.html)과
설계 분석/퀴즈 문서(code_review.html). LiberationSans SDF Fallback 에셋 교체.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:18:32 +09:00

2138 lines
176 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>&lt;<span class="ty">NoteData</span>&gt; 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>&lt;<span class="ty">SongInfo</span>&gt; 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>&lt;<span class="ty">BeatSageNote</span>&gt; _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>&lt;<span class="ty">NoteData</span>&gt; <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>&lt;<span class="ty">NoteData</span>&gt;();
<span class="kw">var</span> root = JsonUtility.<span class="fn">FromJson</span>&lt;<span class="ty">BeatSageRoot</span>&gt;(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> &amp;&amp; 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>&lt;<span class="ty">NoteData</span>&gt; 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>&lt;<span class="ty">BeatSageInfoDat</span>&gt;(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>&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; 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&lt;T&gt; = 반환값 없는 콜백 델리게이트. 코루틴은 값을 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>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">float</span>&gt; onProgress, <span class="ann">// 0.0~1.0 진행률 콜백</span>
<span class="ty">Action</span>&lt;<span class="ty">Dictionary</span>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt;&gt; onComplete, <span class="ann">// 성공: 변환된 맵 전달</span>
<span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>&gt; difficulties, <span class="ty">float</span> bpm,
<span class="ty">Action</span>&lt;<span class="ty">float</span>&gt; onProgress,
<span class="ty">Action</span>&lt;<span class="ty">Dictionary</span>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt;&gt; onComplete,
<span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>&gt; difficulties, <span class="ty">float</span> bpm,
<span class="ty">Action</span>&lt;<span class="ty">float</span>&gt; onProgress,
<span class="ty">Action</span>&lt;<span class="ty">Dictionary</span>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt;&gt; onComplete,
<span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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 &amp;&amp; elapsed &lt; 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 (&gt;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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; 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>&lt;<span class="ty">string</span>&gt; difficulties,
<span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; onSuccess, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>&gt;();
<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>&lt;<span class="ty">IMultipartFormSection</span>&gt;
{
<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>&lt;<span class="ty">string</span>&gt; difficulties,
<span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; onSuccess, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; onError)
{
<span class="kw">var</span> mappedDiffs = <span class="kw">new</span> <span class="ty">List</span>&lt;<span class="ty">string</span>&gt;();
<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>&lt;<span class="ty">IMultipartFormSection</span>&gt;
{
<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>&lt;<span class="ty">string</span>&gt; onStatus, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">byte</span>[]&gt; onSuccess, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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 &lt;= <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> &amp;&amp; attempt &lt; <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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; <span class="fn">ExtractAndConvert</span>(
<span class="kw">byte</span>[] zipBytes, <span class="ty">List</span>&lt;<span class="ty">string</span>&gt; difficulties, <span class="ty">float</span> fallbackBpm)
{
<span class="kw">var</span> result = <span class="kw">new</span> <span class="ty">Dictionary</span>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt;();
<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 &gt; <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 &lt; <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 &gt; 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) =&gt; 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>&lt;<span class="ty">NasConfig</span>&gt;(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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; maps,
<span class="ty">Action</span>&lt;<span class="ty">float</span>&gt; onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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&lt;NoteData&gt;</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>&lt;<span class="ty">string</span>&gt; 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&amp;version=6&amp;method=login"</span>
+ $<span class="st">"&amp;account={UnityWebRequest.EscapeURL(nasAccount)}"</span>
+ $<span class="st">"&amp;passwd={UnityWebRequest.EscapeURL(_password)}"</span>
+ $<span class="st">"&amp;session=FileStation&amp;format=sid&amp;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&amp;version=1&amp;method=logout&amp;session=FileStation&amp;_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>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>&gt; onError)
{
<span class="ty">string</span> uploadUrl = $<span class="st">"{nasBaseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Upload&amp;version=2&amp;method=upload&amp;_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>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">SongsList</span>&gt;(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>&lt;<span class="ty">SongInfo</span>&gt;() };
<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 &lt; <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 &gt; 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 =&gt; 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>&lt;<span class="ty">SongsList</span>&gt; onSuccess, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">SongsList</span>&gt;(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>&lt;<span class="ty">float</span>&gt; onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; onError = <span class="kw">null</span>)
=&gt; <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) =&gt; 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> &amp;&amp; File.<span class="fn">Exists</span>(path);
}
<span class="kw">public string</span> <span class="fn">AudioPath</span>(<span class="ty">string</span> songId) =&gt; 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>&lt;<span class="ty">float</span>&gt; onProgress, <span class="ty">Action</span> onComplete, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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 =&gt; onProgress?.<span class="fn">Invoke</span>(p * <span class="nm">0.7f</span>), <span class="ann">// 0.0~0.7로 스케일</span>
err =&gt; { 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 =&gt; 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 =&gt; { 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>&lt;<span class="ty">float</span>&gt; onProgress, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>&gt; onSuccess, <span class="ty">Action</span>&lt;<span class="ty">string</span>&gt; 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) =&gt; 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 =&gt; 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>&lt;<span class="ty">AudioManager</span>&gt;();
<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>&lt;<span class="ty">MapData</span>&gt;(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) =&gt; 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 &lt; 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>&lt;<span class="ty">NoteData</span>&gt; 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>(() =&gt; _audio.CurrentTime &gt;= 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)
=&gt; (cut &gt;= <span class="nm">0</span> &amp;&amp; cut &lt; 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>(() =&gt; _audio.CurrentTime &gt;= 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>&lt;<span class="ty">AudioSource</span>&gt;();
<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)
=&gt; 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 =&gt; 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 =&gt; Path.<span class="fn">Combine</span>(Application.persistentDataPath, <span class="st">"input"</span>);
<span class="kw">private readonly</span> <span class="ty">List</span>&lt;<span class="ty">string</span>&gt; 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>(() =&gt; <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>&lt;<span class="ty">string</span>&gt;();
<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 &gt; <span class="nm">0</span>;
<span class="kw">if</span> (!hasUrl &amp;&amp; !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>&lt;<span class="ty">string</span>&gt; { <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> &amp;&amp; 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>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; 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 =&gt; {
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 =&gt; maps = result, <span class="ann">// 변환된 맵 수신</span>
onError: err =&gt; { <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 =&gt; {
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: () =&gt; { progressSlider.value = <span class="nm">1f</span>; <span class="fn">SetStatus</span>($<span class="st">"Done! '{song.title}' created."</span>); },
onError: err =&gt; { <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>&lt;<span class="ty">string</span>&gt; 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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; 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 =&gt; { 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 =&gt; maps = result,
onError: err =&gt; { <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 =&gt; { 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: () =&gt; { progressSlider.value = <span class="nm">1f</span>; <span class="fn">SetStatus</span>($<span class="st">"Done! '{song.title}' created."</span>); },
onError: err =&gt; { <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>&lt;<span class="ty">string</span>, <span class="ty">List</span>&lt;<span class="ty">NoteData</span>&gt;&gt; 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> &amp;&amp; meta.bpm &gt; <span class="nm">0</span>) ? meta.bpm : (uiBpm &gt; <span class="nm">0</span> ? uiBpm : fallbackBpm);
<span class="ann">// 제목이 없으면 파일명 → 그것도 없으면 타임스탬프 기반 id</span>
<span class="kw">if</span> (string.<span class="fn">IsNullOrEmpty</span>(title) &amp;&amp; !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>(() =&gt; {
<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 =&gt; Path.<span class="fn">GetFileNameWithoutExtension</span>(f) == Path.<span class="fn">GetFileNameWithoutExtension</span>(fileName));
<span class="kw">if</span> (idx &gt;= <span class="nm">0</span> &amp;&amp; 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&lt;SongDetailPanel, Button&gt; = 이 패널을 받아서 해당 버튼을 반환하는 함수</span>
<span class="ann">// 이렇게 하면 foreach 하나로 4개 난이도를 동일하게 처리 가능 (코드 중복 제거)</span>
<span class="kw">private readonly</span> (<span class="ty">string</span> key, <span class="ty">Func</span>&lt;<span class="ty">SongDetailPanel</span>, <span class="ty">Button</span>&gt; btn)[] diffSlots =
{
(<span class="st">"normal"</span>, p =&gt; p.btnNormal),
(<span class="st">"hard"</span>, p =&gt; p.btnHard),
(<span class="st">"expert"</span>, p =&gt; p.btnExpert),
(<span class="st">"expertplus"</span>, p =&gt; 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 &gt; <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 &amp;&amp; exists;
btn.onClick.<span class="fn">RemoveAllListeners</span>(); <span class="ann">// 이전 곡의 리스너 제거 (메모리 누수 방지)</span>
<span class="kw">if</span> (downloaded &amp;&amp; exists)
{
<span class="ann">// string captured = key: foreach 클로저 버그 방지 (각 람다가 자신의 key를 캡처)</span>
<span class="ty">string</span> captured = key;
btn.onClick.<span class="fn">AddListener</span>(() =&gt; <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 &amp;&amp; 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>(() =&gt; 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>&lt;<span class="ty">string</span>&gt;();
<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 =&gt; {
<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: () =&gt; {
<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 =&gt; { 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>(() =&gt; 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)
=&gt; $<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 =&gt; 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>&lt;<span class="ty">TMP_FontAsset</span>&gt;(<span class="st">"Fonts &amp; 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>&lt;<span class="ty">TMP_FontAsset</span>&gt;(<span class="st">"Fonts &amp; Materials/LiberationSans SDF"</span>);
tabAllBtn .onClick.<span class="fn">AddListener</span>(() =&gt; <span class="fn">SwitchTab</span>(<span class="kw">false</span>));
tabOwnedBtn.onClick.<span class="fn">AddListener</span>(() =&gt; <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 =&gt; {
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: _ =&gt; {
<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 &gt;= <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>&lt;<span class="ty">SongInfo</span>&gt; songs = showingOwned
? allSongs.<span class="fn">FindAll</span>(s =&gt; <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>&lt;<span class="ty">LayoutElement</span>&gt;();
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>&lt;<span class="ty">Image</span>&gt;();
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>&lt;<span class="ty">Button</span>&gt;();
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>&lt;<span class="ty">RectTransform</span>&gt;();
<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>&lt;<span class="ty">RectMask2D</span>&gt;(); <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>&lt;<span class="ty">RectTransform</span>&gt;();
<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>&lt;<span class="ty">TextMeshProUGUI</span>&gt;();
<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>&lt;<span class="ty">MarqueeText</span>&gt;(); <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>(() =&gt; <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>&lt;<span class="ty">SongsList</span>&gt;(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 =&gt; 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 =&gt; 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) =&gt; <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)
=&gt; <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>&lt;<span class="ty">SongInfo</span>&gt; 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 =&gt; !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)
=&gt; _data.entries.<span class="fn">Find</span>(e =&gt; 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>&lt;<span class="ty">LibraryData</span>&gt;(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>() =&gt; 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>&lt;<span class="ty">LibraryEntry</span>&gt; entries = <span class="kw">new</span> <span class="ty">List</span>&lt;<span class="ty">LibraryEntry</span>&gt;();
}
[<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>&lt;<span class="ty">string</span>&gt; difficulties = <span class="kw">new</span> <span class="ty">List</span>&lt;<span class="ty">string</span>&gt;(); <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>&lt;<span class="ty">TMP_Text</span>&gt;();
_rect = <span class="fn">GetComponent</span>&lt;<span class="ty">RectTransform</span>&gt;();
}
<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 &gt; <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 &gt; -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) =&gt;
_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>&lt;<span class="ty">DesktopUIMode</span>&gt;() != <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>&lt;<span class="ty">DesktopUIMode</span>&gt;();
}
<span class="ann">// 씬별 뒤로가기 맵: ESC 누르면 이 씬으로 이동</span>
<span class="kw">private static readonly</span> <span class="ty">Dictionary</span>&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; 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>&lt;<span class="ty">DesktopUIMode</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None).Length &gt; <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>() =&gt; <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) =&gt; <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>&lt;<span class="ty">Canvas</span>&gt;(<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>&lt;<span class="ty">GraphicRaycaster</span>&gt;() == <span class="kw">null</span>)
canvas.gameObject.<span class="fn">AddComponent</span>&lt;<span class="ty">GraphicRaycaster</span>&gt;();
}
}
<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>&lt;<span class="ty">Camera</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None))
<span class="kw">if</span> (c.enabled &amp;&amp; c.gameObject.scene.name != <span class="st">"DontDestroyOnLoad"</span>) { cam = c; <span class="kw">break</span>; }
cam ??= <span class="fn">FindObjectOfType</span>&lt;<span class="ty">Camera</span>&gt;(); <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>&lt;<span class="ty">Canvas</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None))
<span class="kw">if</span> (canvas.renderMode == <span class="ty">RenderMode</span>.WorldSpace &amp;&amp; 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>