Files
BeatSaber/html/code_review.html
T
2026-05-26 19:12:06 +09:00

1174 lines
58 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VR Beat Saber — 코드 리뷰</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2230;
--border: #30363d;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
--orange: #ffa657;
--text: #e6edf3;
--muted: #8b949e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
}
/* ── 사이드바 ── */
#sidebar {
position: fixed; top: 0; left: 0;
width: 240px; height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 20px 0;
z-index: 100;
}
#sidebar h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted);
padding: 0 16px 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
#sidebar a {
display: block;
padding: 5px 16px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
border-left: 2px solid transparent;
transition: all .15s;
}
#sidebar a:hover, #sidebar a.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(88,166,255,.07);
}
#sidebar .section-label {
padding: 12px 16px 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--border);
}
/* ── 메인 ── */
#main {
margin-left: 240px;
padding: 40px 48px;
max-width: 1100px;
}
/* ── 헤더 ── */
.page-header {
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.page-header h1 { font-size: 28px; font-weight: 700; }
.page-header p { color: var(--muted); margin-top: 6px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 6px;
}
.badge-blue { background: rgba(88,166,255,.15); color: var(--accent); }
.badge-green { background: rgba(63,185,80,.15); color: var(--green); }
.badge-yellow { background: rgba(210,153,34,.15); color: var(--yellow); }
.badge-red { background: rgba(248,81,73,.15); color: var(--red); }
.badge-purple { background: rgba(188,140,255,.15);color: var(--purple); }
/* ── 섹션 ── */
section { margin-bottom: 60px; scroll-margin-top: 20px; }
section > h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
color: var(--text);
}
section > h3 {
font-size: 15px;
font-weight: 600;
margin: 28px 0 10px;
color: var(--accent);
}
p { color: var(--muted); margin-bottom: 10px; line-height: 1.7; }
strong { color: var(--text); }
/* ── 카드 ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
}
.card h4 {
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.card p { margin: 0; font-size: 13px; }
/* ── 플로우 다이어그램 ── */
.flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.flow-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.flow-box.highlight { border-color: var(--accent); color: var(--accent); }
.flow-arrow { color: var(--muted); font-size: 16px; }
/* ── 코드 블록 ── */
.code-wrapper {
background: #0a0e14;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin: 12px 0 20px;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
}
.code-filename { font-weight: 600; color: var(--text); }
pre {
padding: 16px 20px;
overflow-x: auto;
font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
}
/* 하이라이트 토큰 */
.kw { color: #ff7b72; } /* keyword */
.ty { color: #ffa657; } /* type */
.fn { color: #d2a8ff; } /* function */
.str { color: #a5d6ff; } /* string */
.num { color: #79c0ff; } /* number */
.cmt { color: #8b949e; font-style: italic; } /* comment */
.var { color: #e6edf3; } /* variable */
.op { color: #ff7b72; } /* operator */
/* ── 포인트 박스 ── */
.point {
border-left: 3px solid;
padding: 10px 16px;
border-radius: 0 6px 6px 0;
margin: 10px 0;
font-size: 13px;
}
.point-blue { border-color: var(--accent); background: rgba(88,166,255,.06); }
.point-green { border-color: var(--green); background: rgba(63,185,80,.06); }
.point-yellow { border-color: var(--yellow); background: rgba(210,153,34,.06); }
.point-red { border-color: var(--red); background: rgba(248,81,73,.06); }
.point p { margin: 0; color: var(--text); }
.point .label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 4px;
}
.point-blue .label { color: var(--accent); }
.point-green .label { color: var(--green); }
.point-yellow .label { color: var(--yellow); }
.point-red .label { color: var(--red); }
/* ── 테이블 ── */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 13px;
}
th {
background: var(--surface);
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
padding: 8px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
td {
padding: 9px 14px;
border-bottom: 1px solid rgba(48,54,61,.5);
color: var(--text);
vertical-align: top;
}
tr:last-child td { border-bottom: none; }
td:first-child { font-family: Consolas, monospace; color: var(--orange); }
td p { margin: 0; font-size: 13px; }
/* ── 체크리스트 ── */
.checklist { list-style: none; padding: 0; }
.checklist li {
padding: 6px 0;
padding-left: 24px;
position: relative;
font-size: 13px;
color: var(--muted);
}
.checklist li::before {
content: '□';
position: absolute;
left: 0;
color: var(--border);
}
.checklist li.done { color: var(--green); }
.checklist li.done::before { content: '✓'; color: var(--green); }
.checklist li.todo::before { content: '○'; color: var(--yellow); }
/* ── Quiz ── */
.quiz {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin: 12px 0;
}
.quiz .q { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
.quiz .answer {
display: none;
background: #0a0e14;
border-radius: 6px;
padding: 10px 14px;
margin-top: 10px;
font-size: 13px;
color: var(--muted);
border-left: 3px solid var(--green);
}
.quiz button {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all .15s;
}
.quiz button:hover { border-color: var(--accent); color: var(--accent); }
/* ── 스크롤바 ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- 사이드바 -->
<nav id="sidebar">
<h2>VR Beat Saber</h2>
<a href="#overview">프로젝트 개요</a>
<a href="#architecture">아키텍처 & 데이터 흐름</a>
<div class="section-label">데이터 모델</div>
<a href="#notedata">NoteData.cs</a>
<a href="#gamesession">GameSession.cs</a>
<div class="section-label">핵심 로직</div>
<a href="#beatconverter">BeatSageConverter.cs</a>
<a href="#beatsageuploader">BeatSageUploader.cs</a>
<a href="#naspublisher">NasPublisher.cs</a>
<a href="#downloadmanager">DownloadManager.cs</a>
<a href="#songcontroller">SongController.cs</a>
<a href="#vrpointer">VR UI 포인터</a>
<div class="section-label">UI</div>
<a href="#songselectmanager">SongSelectManager.cs</a>
<div class="section-label">학습 정리</div>
<a href="#patterns">디자인 패턴</a>
<a href="#unity-tips">Unity 핵심 팁</a>
<a href="#quiz">셀프 퀴즈</a>
<a href="#todo">남은 작업</a>
</nav>
<!-- 메인 -->
<div id="main">
<!-- 헤더 -->
<div class="page-header">
<h1>VR Beat Saber — 코드 리뷰</h1>
<p>Unity 6000.3.12f1 + Meta Quest 기반 커스텀 비트 세이버 클론 | 최신 코드 리뷰 문서</p>
<div style="margin-top:12px">
<span class="badge badge-blue">Unity 6000.3.12f1</span>
<span class="badge badge-green">C#</span>
<span class="badge badge-purple">VRBeatsKit</span>
<span class="badge badge-yellow">Beat Sage API</span>
<span class="badge badge-red">Synology NAS</span>
<span class="badge badge-green">Build: 경고 0 / 오류 0</span>
</div>
</div>
<!-- ───────────────────────────────── 개요 ── -->
<section id="overview">
<h2>프로젝트 개요</h2>
<p>유저가 MP3 파일이나 URL을 올리면 Beat Sage AI가 비트맵을 자동 생성하고, Synology NAS에 저장한 뒤 Quest VR에서 플레이하는 시스템이다.</p>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:16px">
<div class="card">
<h4>SongCreator 씬</h4>
<p>MP3/URL 입력 → Beat Sage API → NAS 업로드. 곡을 시스템에 등록하는 파이프라인.</p>
</div>
<div class="card">
<h4>SongSelect 씬</h4>
<p>NAS에서 songs.json 로드 → 카드 목록 표시 → 다운로드/플레이 선택.</p>
</div>
<div class="card">
<h4>Game 씬</h4>
<p>캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.</p>
</div>
</div>
</section>
<!-- ───────────────────────────────── 아키텍처 ── -->
<section id="architecture">
<h2>아키텍처 & 데이터 흐름</h2>
<h3>전체 파이프라인</h3>
<div class="flow">
<div class="flow-box highlight">SongCreator</div>
<span class="flow-arrow"></span>
<div class="flow-box">BeatSageUploader<br><small style="color:var(--muted)">AI 비트맵 생성</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">BeatSageConverter<br><small style="color:var(--muted)">.dat → NoteData</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">NasPublisher<br><small style="color:var(--muted)">NAS 업로드</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">songs.json 갱신</div>
</div>
<div class="flow" style="margin-top:4px">
<div class="flow-box highlight">SongSelect</div>
<span class="flow-arrow"></span>
<div class="flow-box">DownloadManager<br><small style="color:var(--muted)">NAS → 로컬 캐시</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">GameSession<br><small style="color:var(--muted)">씬 간 선택 전달</small></div>
<span class="flow-arrow"></span>
<div class="flow-box highlight">Game 씬</div>
<span class="flow-arrow"></span>
<div class="flow-box">SongController<br><small style="color:var(--muted)">로드 + 스폰</small></div>
</div>
<h3>캐시 경로 규칙</h3>
<div class="code-wrapper">
<div class="code-header">
<span class="code-filename">Cache Layout</span>
<span>Application.temporaryCachePath</span>
</div>
<pre><span class="cmt">// DownloadManager, SongController 공통 경로 — 항상 일치해야 함</span>
Application.temporaryCachePath/beatsaber/
{songId}/
{songId}.mp3 <span class="cmt">← 오디오</span>
Map_{songId}_normal.json <span class="cmt">← 난이도별 맵 JSON</span>
Map_{songId}_hard.json
Map_{songId}_expert.json
Map_{songId}_expertplus.json</pre>
</div>
<div class="point point-yellow">
<div class="label">주의</div>
<p><code>SongController</code><code>DownloadManager</code>가 각자 <code>CacheRoot</code> 프로퍼티를 중복 선언한다. 경로가 달라지면 파일을 못 찾으므로 공용 상수로 리팩토링하면 좋다.</p>
</div>
<h3>스크립트 의존 관계</h3>
<table>
<tr><th>스크립트</th><th>의존 대상</th><th>의존 방식</th></tr>
<tr><td>SongController</td><td>GameSession, AudioManager, VR_BeatManager</td><td>static / FindFirstObjectByType / singleton</td></tr>
<tr><td>SongSelectManager</td><td>DownloadManager, SongDetailPanel, SongLibrary</td><td>SerializeField / singleton</td></tr>
<tr><td>NasPublisher</td><td>BeatSageConverter</td><td>static class 직접 호출</td></tr>
<tr><td>BeatSageUploader</td><td>BeatSageConverter, NoteData</td><td>static class 직접 호출</td></tr>
<tr><td>DownloadManager</td><td>NoteData (SongInfo)</td><td>파라미터</td></tr>
<tr><td>VRPointerSetup</td><td>VRPointerController, SceneManager</td><td>RuntimeInitializeOnLoadMethod / sceneLoaded</td></tr>
<tr><td>VRPointerController</td><td>Selectable, EventSystem, XR InputDevice</td><td>직접 Ray/Rect 교차 + ExecuteEvents</td></tr>
</table>
</section>
<!-- ───────────────────────────────── NoteData ── -->
<section id="notedata">
<h2>NoteData.cs — 데이터 모델 계층</h2>
<p>프로젝트의 모든 데이터 구조가 한 파일에 정의되어 있다. Unity의 <code>JsonUtility</code>와 호환되도록 <strong>[Serializable]</strong> 속성이 붙어 있다.</p>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">NoteData.cs</span></div>
<pre><span class="kw">public class</span> <span class="ty">NoteData</span>
{
<span class="kw">public</span> <span class="ty">float</span> time; <span class="cmt">// 초 단위 (비트 → 초 변환 후)</span>
<span class="kw">public</span> <span class="ty">int</span> position; <span class="cmt">// 열 0-3</span>
<span class="kw">public</span> <span class="ty">int</span> lineLayer; <span class="cmt">// 행 0-2</span>
<span class="kw">public</span> <span class="ty">int</span> colorType; <span class="cmt">// 0=빨강, 1=파랑</span>
<span class="kw">public</span> <span class="ty">int</span> cutDirection; <span class="cmt">// 0-8 (Beat Saber 스펙)</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="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="cmt">// C# 8 switch expression</span>
{
<span class="str">"normal"</span> <span class="op">=></span> normal,
<span class="str">"hard"</span> <span class="op">=></span> hard,
<span class="str">"expert"</span> <span class="op">=></span> expert,
<span class="str">"expertplus"</span> <span class="op">=></span> expertplus,
_ <span class="op">=></span> <span class="kw">null</span>
};
}</pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트</div>
<p><strong>C# 8 switch expression</strong>: 전통적인 <code>switch</code>문 대신 람다처럼 쓸 수 있는 표현식. <code>_</code>는 default 케이스다. 반환값이 있는 간단한 매핑에 적합.</p>
</div>
<div class="point point-yellow">
<div class="label">설계 트레이드오프</div>
<p><code>DifficultyMap</code>은 필드 4개가 하드코딩되어 있다. 난이도가 추가되면 클래스를 수정해야 한다. <code>Dictionary&lt;string, DifficultyInfo&gt;</code>로 바꾸면 유연하지만 <code>JsonUtility</code>가 Dictionary를 직렬화하지 못하므로 <code>Newtonsoft.Json</code>이 필요하다.</p>
</div>
</section>
<!-- ───────────────────────────────── GameSession ── -->
<section id="gamesession">
<h2>GameSession.cs — 씬 간 데이터 전달</h2>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">GameSession.cs</span></div>
<pre><span class="cmt">// static container — 씬이 바뀌어도 메모리에 남는다</span>
<span class="kw">public static class</span> <span class="ty">GameSession</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;
}</pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — static class란?</div>
<p>인스턴스를 생성할 수 없고, 모든 멤버가 자동으로 <code>static</code>이다. <code>MonoBehaviour</code>를 상속하지 않으므로 씬이 바뀌어도 값이 유지된다. Unity에서 씬 간 데이터를 넘기는 가장 단순한 방법.</p>
</div>
<div class="point point-yellow">
<div class="label">대안 비교</div>
<p>
<strong>DontDestroyOnLoad</strong>: MonoBehaviour 기반, 씬 전환 후에도 GameObject 유지.<br>
<strong>ScriptableObject</strong>: Inspector에서 확인 가능, 에디터 재시작 전까지 유지.<br>
<strong>PlayerPrefs</strong>: 앱 재시작 후에도 유지 (영구 저장).<br>
<strong>static class</strong>: 가장 단순. 앱 종료하면 사라짐. 이 프로젝트엔 충분.
</p>
</div>
</section>
<!-- ───────────────────────────────── BeatSageConverter ── -->
<section id="beatconverter">
<h2>BeatSageConverter.cs — 포맷 변환</h2>
<p>Beat Saber의 <code>.dat</code> JSON 포맷을 우리 내부 <code>NoteData</code>로 변환하는 순수 로직 클래스다.</p>
<h3>핵심 변환: 비트 → 초</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageConverter.cs — Convert()</span></div>
<pre><span class="cmt">// Beat Saber의 _time은 "비트 단위"</span>
<span class="cmt">// 실제 시간(초) = 비트 / BPM * 60</span>
time = (note._time * <span class="num">60f</span>) / bpm,
<span class="cmt">// 예: BPM=120, _time=4 → 4/120*60 = 2.0초</span>
<span class="cmt">// 폭탄(type=3), 장애물 등은 스킵</span>
<span class="kw">if</span> (note._type != <span class="num">0</span> && note._type != <span class="num">1</span>) <span class="kw">continue</span>;</pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — static class 사용 이유</div>
<p>상태(필드)가 전혀 없는 순수 함수들의 모음이다. 인스턴스를 만들 필요가 없으므로 <code>static class</code>가 적합. C#의 <strong>유틸리티 클래스 패턴</strong>.</p>
</div>
<h3>BPM 자동 감지 (info.dat)</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageConverter.cs — ParseInfoDat()</span></div>
<pre><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>
{
title = (info._songName ?? <span class="str">""</span>).<span class="fn">Trim</span>(), <span class="cmt">// null 병합 연산자</span>
artist = (info._songAuthorName ?? <span class="str">""</span>).<span class="fn">Trim</span>(),
bpm = info._beatsPerMinute,
};
}</pre>
</div>
<div class="point point-green">
<div class="label">null 병합 연산자 ??</div>
<p><code>a ?? b</code> — a가 null이면 b를 반환. <code>a != null ? a : b</code>의 축약형. JSON 파싱 시 누락된 필드가 null로 들어올 때 안전하게 처리.</p>
</div>
</section>
<!-- ───────────────────────────────── BeatSageUploader ── -->
<section id="beatsageuploader">
<h2>BeatSageUploader.cs — 외부 API 연동</h2>
<p>Beat Sage 서버에 오디오를 올리고, 생성 완료를 폴링한 뒤, 결과 ZIP을 다운받아 변환까지 처리한다.</p>
<h3>코루틴 체이닝 구조</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageUploader.cs — Upload() 흐름</span></div>
<pre>Upload() / UploadFromUrl()
└─ <span class="fn">CreateLevel</span>() / <span class="fn">CreateLevelFromUrl</span>() <span class="cmt">// [1/4] POST → levelId 획득</span>
└─ <span class="fn">PollAndDownload</span>() <span class="cmt">// 공통 phase 2~4</span>
├─ <span class="fn">PollHeartbeat</span>() (5초 간격, 최대 300초) <span class="cmt">// [2/4] 생성 완료 대기</span>
├─ <span class="fn">DownloadZip</span>() (최대 3회 재시도) <span class="cmt">// [3/4] ZIP 다운로드</span>
└─ <span class="fn">ExtractAndConvert</span>() <span class="cmt">// [4/4] .dat → NoteData</span></pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — yield return StartCoroutine()</div>
<p>코루틴 안에서 다른 코루틴을 <code>yield return StartCoroutine()</code>으로 호출하면 내부 코루틴이 끝날 때까지 기다린다. 순차적인 비동기 작업을 async/await 없이 체이닝하는 Unity 방식.</p>
</div>
<h3>멀티파트 폼 — URL vs 파일</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageUploader.cs</span></div>
<pre><span class="cmt">// URL 업로드: audio_url 필드에 문자열</span>
<span class="kw">new</span> <span class="ty">MultipartFormDataSection</span>(<span class="str">"audio_url"</span>, audioUrl)
<span class="cmt">// 파일 업로드: 바이트 배열 + MIME 타입</span>
<span class="kw">new</span> <span class="ty">MultipartFormFileSection</span>(<span class="str">"audio_file"</span>, audioBytes, fileName, <span class="str">"audio/mpeg"</span>)</pre>
</div>
<h3>폴링 타임아웃 패턴</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageUploader.cs — PollAndDownload()</span></div>
<pre><span class="kw">float</span> elapsed = <span class="num">0f</span>;
<span class="kw">while</span> (!ready && elapsed < POLL_TIMEOUT) <span class="cmt">// 300초 초과 시 탈출</span>
{
<span class="kw">yield return new</span> <span class="ty">WaitForSeconds</span>(POLL_INTERVAL); <span class="cmt">// 5초 대기</span>
elapsed += POLL_INTERVAL;
<span class="kw">yield return</span> <span class="fn">PollHeartbeat</span>(levelId, status => {
ready = status == <span class="str">"generated"</span> || status == <span class="str">"done"</span>;
error = status == <span class="str">"error"</span>;
}, onError);
onProgress?.<span class="fn">Invoke</span>(<span class="num">0.15f</span> + Mathf.<span class="fn">Clamp01</span>(elapsed / POLL_TIMEOUT) * <span class="num">0.6f</span>);
}</pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — Action 콜백 패턴</div>
<p>Unity 코루틴은 값을 <code>return</code>할 수 없다. 대신 <strong>콜백(Action)</strong>을 파라미터로 받아서 결과를 전달한다. <code>onSuccess</code>, <code>onError</code>가 대표적. <code>?.</code>는 null 조건부 호출 — null이면 아무것도 안 함.</p>
</div>
<h3>간이 JSON 파싱</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">BeatSageUploader.cs — ParseJsonString()</span></div>
<pre><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="str">"\"{key}\":"</span>; <span class="cmt">// "id":" 를 찾음</span>
<span class="kw">int</span> start = json.<span class="fn">IndexOf</span>(search);
<span class="kw">if</span> (start < <span class="num">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="str">'"'</span>, start); <span class="cmt">// 닫는 " 위치</span>
<span class="kw">return</span> end > start ? json.<span class="fn">Substring</span>(start, end - start) : <span class="kw">null</span>;
}</pre>
</div>
<div class="point point-yellow">
<div class="label">주의 — 수동 파싱의 한계</div>
<p>Beat Sage 응답이 간단해서 <code>IndexOf</code>로 파싱하지만, JSON 값에 이스케이프된 따옴표(<code>\"</code>)가 있으면 틀린다. 복잡한 응답엔 <code>JsonUtility</code><code>Newtonsoft.Json</code>을 써야 한다.</p>
</div>
</section>
<!-- ───────────────────────────────── NasPublisher ── -->
<section id="naspublisher">
<h2>NasPublisher.cs — Synology DSM API</h2>
<p>DSM FileStation API를 통해 NAS에 파일을 업로드한다. <strong>로그인 → 업로드 → 로그아웃</strong> 세션 흐름을 코루틴 체이닝으로 구현.</p>
<h3>수동 multipart body 구성 — 핵심 패턴</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">NasPublisher.cs — UploadBytes()</span></div>
<pre><span class="cmt">// Unity 기본 UnityWebRequest.Post(form) 는 DSM에서 401 반환 → 수동 구성 필요</span>
<span class="ty">string</span> boundary = Guid.<span class="fn">NewGuid</span>().<span class="fn">ToString</span>(<span class="str">"N"</span>); <span class="cmt">// 랜덤 경계 문자열</span>
<span class="kw">using var</span> body = <span class="kw">new</span> <span class="ty">MemoryStream</span>();
<span class="cmt">// 텍스트 필드</span>
WriteField(<span class="str">"path"</span>, nasFolder);
WriteField(<span class="str">"create_parents"</span>, <span class="str">"true"</span>);
WriteField(<span class="str">"overwrite"</span>, <span class="str">"true"</span>);
<span class="cmt">// 파일 파트</span>
WriteText($<span class="str">"--{boundary}\r\n"</span>);
WriteText($<span class="str">"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"\r\n"</span>);
WriteText(<span class="str">"Content-Type: application/octet-stream\r\n\r\n"</span>);
body.<span class="fn">Write</span>(bytes, <span class="num">0</span>, bytes.Length);
WriteText(<span class="str">"\r\n--{boundary}--\r\n"</span>); <span class="cmt">// 종료 경계</span>
req.uploadHandler = <span class="kw">new</span> <span class="ty">UploadHandlerRaw</span>(body.<span class="fn">ToArray</span>());
req.<span class="fn">SetRequestHeader</span>(<span class="str">"Content-Type"</span>, $<span class="str">"multipart/form-data; boundary={boundary}"</span>);</pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — multipart/form-data 구조</div>
<p>
<code>--boundary</code> 로 각 파트를 구분하고, 마지막은 <code>--boundary--</code>로 끝낸다.<br>
<code>Content-Disposition</code><code>name</code>(필드명)과 파일이면 <code>filename</code>을 명시.<br>
바이너리 데이터는 <code>MemoryStream</code>에 직접 <code>Write()</code>.
</p>
</div>
<h3>songs.json Patch 전략</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">NasPublisher.cs — PatchSongsJson()</span></div>
<pre><span class="cmt">// 기존 목록 읽기 → 없으면 새로 생성 (null 병합)</span>
list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version = <span class="str">"1.0"</span>, songs = <span class="kw">new</span> <span class="ty">List</span>&lt;<span class="ty">SongInfo</span>&gt;() };
<span class="cmt">// 같은 id가 있으면 교체, 없으면 추가 — upsert 패턴</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="num">0</span>) list.songs[idx] = newSong;
<span class="kw">else</span> list.songs.<span class="fn">Add</span>(newSong);</pre>
</div>
<div class="point point-green">
<div class="label">학습 포인트 — Upsert 패턴</div>
<p>DB의 INSERT OR UPDATE와 동일 개념. 리스트에서 인덱스를 찾아 있으면 교체, 없으면 추가. <code>FindIndex</code>는 조건 람다를 받아 인덱스를 반환하며, 없으면 <code>-1</code>.</p>
</div>
</section>
<!-- ───────────────────────────────── DownloadManager ── -->
<section id="downloadmanager">
<h2>DownloadManager.cs — 파일 다운로드 캐시</h2>
<p>NAS 정적 서버에서 MP3와 맵 JSON을 로컬 캐시로 내려받는다. <strong>오디오 70% + 맵 30%</strong> 비율로 진행률을 계산.</p>
<h3>진행률 분할 계산</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">DownloadManager.cs — DownloadSongCoroutine()</span></div>
<pre><span class="cmt">// 1단계: 오디오 (전체의 0% ~ 70%)</span>
<span class="kw">yield return</span> <span class="fn">DownloadFile</span>(audioUrl, audioPath,
p => onProgress?.<span class="fn">Invoke</span>(p * <span class="num">0.7f</span>), <span class="cmt">// 0.0 ~ 0.7</span>
err => { ... });
<span class="cmt">// 2단계: 맵 (전체의 70% ~ 100%)</span>
<span class="kw">yield return</span> <span class="fn">DownloadFile</span>(mapUrl, mapPath,
p => onProgress?.<span class="fn">Invoke</span>(<span class="num">0.7f</span> + p * <span class="num">0.3f</span>), <span class="cmt">// 0.7 ~ 1.0</span>
err => { ... });</pre>
</div>
<h3>이미 있으면 스킵</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">DownloadManager.cs</span></div>
<pre><span class="cmt">// 캐시 히트 → 다운로드 건너뜀 (멱등성 보장)</span>
<span class="kw">if</span> (!File.<span class="fn">Exists</span>(audioPath))
{
<span class="kw">yield return</span> <span class="fn">DownloadFile</span>(...);
}</pre>
</div>
<div class="point point-green">
<div class="label">학습 포인트 — 멱등성(Idempotency)</div>
<p>같은 작업을 여러 번 해도 결과가 동일한 성질. 이미 다운로드된 파일은 재다운로드하지 않으므로 <strong>다운로드 중 앱 종료 후 재시도해도 안전</strong>하다. 단, 오디오는 있고 맵이 없으면 오디오를 스킵하고 맵만 받는다.</p>
</div>
<div class="point point-yellow">
<div class="label">주의 — 실패 시 불완전 파일</div>
<p><code>DownloadHandlerFile</code>이 실패하면 <code>File.Delete(savePath)</code>로 정리한다. 그렇지 않으면 0바이트 파일이 남아 다음에 "캐시 히트"로 오판할 수 있다.</p>
</div>
</section>
<!-- ───────────────────────────────── SongController ── -->
<section id="songcontroller">
<h2>SongController.cs — Game 씬 핵심 브릿지</h2>
<p>Game 씬이 시작되면 GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고, 카운트다운 후 큐브를 스폰한다.</p>
<h3>전체 코루틴 흐름</h3>
<div class="flow">
<div class="flow-box">Start()</div>
<span class="flow-arrow"></span>
<div class="flow-box">LoadAndPlay()</div>
<span class="flow-arrow"></span>
<div class="flow-box">GetAudioClip<br><small style="color:var(--muted)">비동기</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">File.ReadAllText<br><small style="color:var(--muted)">동기</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">Countdown<br><small style="color:var(--muted)">3,2,1,GO</small></div>
<span class="flow-arrow"></span>
<div class="flow-box highlight">SpawnRoutine</div>
<span class="flow-arrow">+</span>
<div class="flow-box highlight">WaitForCompletion</div>
</div>
<h3>travelTimeOverride — 동시 노트 보정</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongController.cs — SpawnNote()</span></div>
<pre><span class="kw">private void</span> <span class="fn">SpawnNote</span>(<span class="ty">NoteData</span> note)
{
<span class="cmt">// 스폰 시점의 실제 남은 시간으로 travelTime을 역산</span>
<span class="cmt">// → foreach 순차 처리로 생기는 16ms 프레임 차이를 흡수</span>
<span class="ty">float</span> remaining = note.time - _audio.CurrentTime;
<span class="ty">float</span> travelTime = Mathf.<span class="fn">Max</span>(<span class="num">0.05f</span>, remaining);
<span class="kw">var</span> info = <span class="kw">new</span> <span class="ty">SpawnEventInfo</span>
{
travelTimeOverride = travelTime, <span class="cmt">// VRBeatsKit이 이 값을 우선 사용</span>
...
};
}</pre>
</div>
<h3>오디오 싱크 — DSP 기준 예약 재생</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">AudioManager.cs — PlayClipScheduled()</span></div>
<pre><span class="kw">public double</span> <span class="fn">PlayClipScheduled</span>(<span class="ty">AudioClip</span> clip, <span class="kw">double</span> delaySeconds = <span class="num">0.1</span>)
{
audioSource.<span class="fn">Stop</span>();
audioSource.clip = clip;
audioSource.time = <span class="num">0.0f</span>;
scheduledDspStartTime = <span class="ty">AudioSettings</span>.dspTime + delaySeconds;
hasScheduledClip = <span class="kw">true</span>;
audioSource.<span class="fn">PlayScheduled</span>(scheduledDspStartTime);
<span class="kw">return</span> scheduledDspStartTime;
}
<span class="kw">public float</span> CurrentTime
{
<span class="kw">get</span>
{
<span class="kw">if</span> (hasScheduledClip)
<span class="kw">return</span> (<span class="kw">float</span>)(<span class="ty">AudioSettings</span>.dspTime - scheduledDspStartTime);
<span class="kw">return</span> audioSource.time;
}
}</pre>
</div>
<div class="point point-green">
<div class="label">개선 완료</div>
<p><code>AudioSource.Play()</code> 대신 <code>PlayScheduled()</code>를 사용해 음악 시작 시점을 DSP 시간에 고정했다. 노트 스폰 기준도 <code>AudioSettings.dspTime</code> 차이로 계산하므로 프레임 상태에 따른 싱크 흔들림이 줄었다.</p>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — 타이밍 보정 기법</div>
<p>
<strong>문제:</strong> BPM=120에서 동시 노트 2개가 foreach로 처리되면 1프레임(16ms) 차이가 난다.<br>
<strong>해결:</strong> travelTime을 "설정값"이 아니라 "스폰 시점에서 목표 시간까지 남은 실제 시간"으로 주입.<br>
노트 A를 스폰할 때 remaining=1.800, B를 스폰할 때 remaining=1.784 → 각각 다른 speed로 발사되어 <strong>히트존에 동시 도착</strong>.
</p>
</div>
<h3>cutDirection 매핑 — 조회 테이블</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongController.cs</span></div>
<pre><span class="cmt">// if-else 체인 대신 배열 인덱스로 O(1) 매핑</span>
<span class="kw">private static readonly</span> <span class="ty">Direction</span>[] CutDirMap =
{
Direction.Up, Direction.Down, Direction.Left, Direction.Right,
Direction.UpperLeft, Direction.UpperRight, Direction.LowerLeft, Direction.LowerRight,
Direction.Center, <span class="cmt">// index 8 = Any/Dot</span>
};
<span class="kw">private static</span> <span class="ty">Direction</span> <span class="fn">MapCutDirection</span>(<span class="ty">int</span> cut)
<span class="op">=></span> (cut >= <span class="num">0</span> && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;</pre>
</div>
<div class="point point-green">
<div class="label">학습 포인트 — 조회 테이블(Lookup Table)</div>
<p>switch/if-else 9개 대신 <code>static readonly</code> 배열 + 인덱스 접근. 컴파일 타임에 메모리를 할당하고 GC 없이 재사용. 매핑 항목이 많을수록 코드가 깔끔하고 빠르다.</p>
</div>
<h3>위치 계산</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongController.cs</span></div>
<pre><span class="kw">private const float</span> LaneSpacing = <span class="num">0.42f</span>;
<span class="kw">private const float</span> LayerSpacing = <span class="num">0.38f</span>;
<span class="kw">private const float</span> HorizontalCenter = <span class="num">1.5f</span>;
<span class="kw">private const float</span> VerticalCenter = <span class="num">1f</span>;
<span class="kw">private static float</span> <span class="fn">MapLaneX</span>(<span class="ty">int</span> position)
{
<span class="ty">int</span> lane = Mathf.<span class="fn">Clamp</span>(position, <span class="num">0</span>, <span class="num">3</span>);
<span class="kw">return</span> (lane - HorizontalCenter) * LaneSpacing;
}
<span class="kw">private static float</span> <span class="fn">MapLayerY</span>(<span class="ty">int</span> lineLayer)
{
<span class="ty">int</span> layer = Mathf.<span class="fn">Clamp</span>(lineLayer, <span class="num">0</span>, <span class="num">2</span>);
<span class="kw">return</span> (layer - VerticalCenter) * LayerSpacing;
}</pre>
</div>
<div class="point point-green">
<div class="label">개선 완료 — 가로 겹침</div>
<p>기존 라인 간격은 <code>0.25</code>였고 큐브 실제 폭은 약 <code>0.36</code>이라 인접 라인이 겹칠 수밖에 없었다. 현재 X 좌표는 대략 <code>-0.63, -0.21, 0.21, 0.63</code>으로 벌어져 가로 겹침을 피한다.</p>
</div>
</section>
<!-- ───────────────────────────────── VR Pointer ── -->
<section id="vrpointer">
<h2>VRPointerController / VRPointerSetup — VR UI 클릭 안정화</h2>
<p>게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.</p>
<h3>구조</h3>
<div class="flow">
<div class="flow-box">VRPointerSetup<br><small style="color:var(--muted)">BeforeSceneLoad 자동 생성</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">SceneManager.sceneLoaded</div>
<span class="flow-arrow"></span>
<div class="flow-box">Controller/Hand + LineRenderer 탐색</div>
<span class="flow-arrow"></span>
<div class="flow-box highlight">VRPointerController 주입</div>
</div>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">VRPointerController.cs — 클릭 처리</span></div>
<pre><span class="kw">private static void</span> <span class="fn">Click</span>(<span class="ty">Selectable</span> sel)
{
<span class="kw">var</span> es = <span class="ty">EventSystem</span>.current;
<span class="kw">var</span> eventData = <span class="kw">new</span> <span class="ty">PointerEventData</span>(es);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerDownHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerUpHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerClickHandler);
<span class="kw">var</span> btn = sel.<span class="fn">GetComponent</span>&lt;<span class="ty">Button</span>&gt;();
<span class="kw">if</span> (btn != <span class="kw">null</span>) btn.onClick.<span class="fn">Invoke</span>();
}</pre>
</div>
<div class="point point-blue">
<div class="label">평가</div>
<p>XR UI 모듈 설정에 의존하지 않고 Selectable을 직접 Ray/Rect 교차 검사하는 방식이라 씬별 Raycaster 설정 차이에 강하다. 단, 커스텀 Selectable이나 비사각형 UI가 늘어나면 GraphicRaycaster 기반으로 재검토할 수 있다.</p>
</div>
<div class="point point-yellow">
<div class="label">실기 확인 필요</div>
<p>Game 씬에서는 포인터가 기본 비활성이고 게임오버 시 <code>VR_InteractorController</code>를 통해 활성화된다. Quest에서 Back/Retry, SongCreator 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.</p>
</div>
</section>
<!-- ───────────────────────────────── SongSelectManager ── -->
<section id="songselectmanager">
<h2>SongSelectManager.cs — 동적 UI 생성</h2>
<p>Inspector에서 프리팹을 쓰지 않고 <strong>코드로 직접 GameObject를 생성</strong>해 카드 리스트를 만든다.</p>
<h3>카드 생성 패턴</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongSelectManager.cs — SpawnCard()</span></div>
<pre><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, worldPositionStays: <span class="kw">false</span>); <span class="cmt">// false = 로컬 유지</span>
<span class="kw">var</span> bg = card.<span class="fn">AddComponent</span>&lt;<span class="ty">Image</span>&gt;();
<span class="kw">var</span> btn = card.<span class="fn">AddComponent</span>&lt;<span class="ty">Button</span>&gt;();
<span class="cmt">// 클로저 캡처 버그 방지 — foreach 루프 변수를 로컬에 복사</span>
<span class="ty">SongInfo</span> captured = song;
btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardClicked</span>(captured));</pre>
</div>
<div class="point point-red">
<div class="label">중요 — 클로저 캡처 버그</div>
<p>
<code>foreach</code>에서 람다로 루프 변수를 캡처하면, 루프가 끝난 뒤 모든 버튼이 <strong>마지막 곡</strong>을 가리킨다.<br>
<code>SongInfo captured = song;</code> 으로 로컬 복사본을 만들어야 각 클로저가 자신의 값을 가진다.<br>
C# 5 이후 <code>foreach</code>는 수정됐지만, <code>for(int i=0; ...)</code>에선 여전히 발생하므로 습관적으로 복사하는 게 좋다.
</p>
</div>
<h3>마퀴 스크롤 구조</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongSelectManager.cs — 마퀴 레이아웃</span></div>
<pre>card
└─ TitleMask (<span class="ty">RectMask2D</span>) <span class="cmt">← 클리핑 컨테이너</span>
└─ Title (<span class="ty">TMP_Text</span> + <span class="ty">MarqueeText</span>) <span class="cmt">← 텍스트가 마스크 밖으로 스크롤</span>
└─ Artist (<span class="ty">TMP_Text</span>)
└─ Badge (<span class="ty">Image</span> + <span class="ty">TMP_Text</span>) <span class="cmt">← 다운로드된 곡에만 표시</span></pre>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — RectMask2D</div>
<p>Mask 컴포넌트와 달리 스텐실 버퍼를 쓰지 않아 오버헤드가 적다. RectTransform 영역 밖의 자식을 잘라낸다. 텍스트가 컨테이너 너비를 넘어도 잘리고, 자식이 스크롤하면 마퀴처럼 보인다.</p>
</div>
<h3>오프라인 폴백 (캐시)</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongSelectManager.cs — FetchSongs()</span></div>
<pre>downloadManager.<span class="fn">FetchSongsList</span>(
onSuccess: list => {
<span class="fn">SaveCache</span>(list); <span class="cmt">// 성공하면 캐시 갱신</span>
<span class="fn">RefreshCards</span>();
},
onError: _ => {
<span class="ty">SongsList</span> cached = <span class="fn">LoadCache</span>(); <span class="cmt">// 실패하면 캐시로 폴백</span>
<span class="kw">if</span> (cached != <span class="kw">null</span>) <span class="fn">RefreshCards</span>();
<span class="kw">else</span> errorOverlay.<span class="fn">SetActive</span>(<span class="kw">true</span>); <span class="cmt">// 캐시도 없으면 에러 표시</span>
});</pre>
</div>
</section>
<!-- ───────────────────────────────── 패턴 ── -->
<section id="patterns">
<h2>디자인 패턴 정리</h2>
<table>
<tr><th>패턴</th><th>사용 위치</th><th>설명</th></tr>
<tr>
<td>Coroutine Chaining</td>
<td>BeatSageUploader, SongController, NasPublisher</td>
<td><p>yield return StartCoroutine()으로 비동기 작업을 순차 실행. async/await 대신 Unity 방식.</p></td>
</tr>
<tr>
<td>Callback (Action)</td>
<td>DownloadManager, BeatSageUploader, NasPublisher</td>
<td><p>onSuccess / onError / onProgress 콜백으로 코루틴 결과 전달.</p></td>
</tr>
<tr>
<td>Static Class (전역 상태)</td>
<td>GameSession, BeatSageConverter, SongLibrary</td>
<td><p>GameSession은 씬 간 데이터 전달, BeatSageConverter는 순수 유틸리티.</p></td>
</tr>
<tr>
<td>Lookup Table</td>
<td>SongController.CutDirMap, BeatSageUploader.DiffNames</td>
<td><p>static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.</p></td>
</tr>
<tr>
<td>Deterministic Sort</td>
<td>SongController.CompareNotes()</td>
<td><p>time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.</p></td>
</tr>
<tr>
<td>Runtime Injection</td>
<td>VRPointerSetup</td>
<td><p>씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.</p></td>
</tr>
<tr>
<td>Upsert</td>
<td>NasPublisher.PatchSongsJson()</td>
<td><p>FindIndex로 기존 항목 교체, 없으면 추가. DB의 INSERT OR REPLACE와 동일.</p></td>
</tr>
<tr>
<td>Offline Fallback</td>
<td>SongSelectManager.FetchSongs()</td>
<td><p>네트워크 실패 시 로컬 캐시 JSON을 사용. 오프라인에서도 목록 표시 가능.</p></td>
</tr>
<tr>
<td>Idempotent Cache</td>
<td>DownloadManager.DownloadSongCoroutine()</td>
<td><p>파일 존재 여부 확인 후 스킵. 중복 다운로드 없음.</p></td>
</tr>
</table>
</section>
<!-- ───────────────────────────────── Unity 팁 ── -->
<section id="unity-tips">
<h2>Unity 핵심 팁 (이 프로젝트에서 배우는 것)</h2>
<h3>1. UnityWebRequest 패턴</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">일반 GET</span></div>
<pre><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) { <span class="cmt">/* 에러 처리 */</span> }
<span class="ty">string</span> text = req.downloadHandler.text;</pre>
</div>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">오디오 클립 로드</span></div>
<pre><span class="kw">using var</span> req = <span class="ty">UnityWebRequestMultimedia</span>.<span class="fn">GetAudioClip</span>(<span class="str">"file://"</span> + path, <span class="ty">AudioType</span>.MPEG);
<span class="kw">yield return</span> req.<span class="fn">SendWebRequest</span>();
<span class="ty">AudioClip</span> clip = <span class="ty">DownloadHandlerAudioClip</span>.<span class="fn">GetContent</span>(req);</pre>
</div>
<h3>2. WaitUntil — 조건 기반 대기</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongController.cs</span></div>
<pre><span class="cmt">// 조건이 true가 될 때까지 매 프레임 체크</span>
<span class="kw">yield return new</span> <span class="ty">WaitUntil</span>(() => _audio.CurrentTime >= spawnAt);</pre>
</div>
<h3>3. LayoutRebuilder — 동적 UI 갱신</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongSelectManager.cs</span></div>
<pre><span class="cmt">// 자식을 코드로 추가한 뒤 레이아웃 강제 재계산</span>
<span class="ty">LayoutRebuilder</span>.<span class="fn">ForceRebuildLayoutImmediate</span>(cardContainer);
<span class="ty">Canvas</span>.<span class="fn">ForceUpdateCanvases</span>();</pre>
</div>
<h3>4. using 선언 (C# 8)</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">C# 8 using 선언</span></div>
<pre><span class="cmt">// 블록 없이 — 변수가 선언된 스코프 끝에서 자동 Dispose</span>
<span class="kw">using var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url);
<span class="kw">using var</span> ms = <span class="kw">new</span> <span class="ty">MemoryStream</span>(bytes);
<span class="cmt">// 전통 방식 (C# 7 이하)</span>
<span class="kw">using</span> (<span class="kw">var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url)) { ... }</pre>
</div>
<h3>5. Unity 6 API 전환</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">deprecated API 정리</span></div>
<pre><span class="cmt">// 이전</span>
FindObjectOfType&lt;<span class="ty">AudioManager</span>&gt;();
FindObjectsOfType&lt;<span class="ty">Canvas</span>&gt;();
tTmp.enableWordWrapping = <span class="kw">false</span>;
<span class="cmt">// 현재</span>
FindFirstObjectByType&lt;<span class="ty">AudioManager</span>&gt;();
FindObjectsByType&lt;<span class="ty">Canvas</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None);
tTmp.textWrappingMode = <span class="ty">TextWrappingModes</span>.NoWrap;</pre>
</div>
<div class="point point-green">
<div class="label">현재 상태</div>
<p><code>dotnet build VRBeatSaber.slnx --no-incremental</code> 기준 경고 0개, 오류 0개다. VRBeatsKit 내부 deprecated API와 미사용 필드 경고까지 정리되어 있다.</p>
</div>
<h3>6. Unity ?? 연산자 주의사항</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SaberGlow.cs 버그 사례</span></div>
<pre><span class="cmt">// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함</span>
<span class="kw">var</span> light = _pointLight ?? <span class="fn">GetComponent</span>&lt;<span class="ty">Light</span>&gt;(); <span class="cmt">// ← 위험!</span>
<span class="cmt">// 수정: 명시적 GetComponent만 사용</span>
<span class="kw">var</span> light = <span class="fn">GetComponent</span>&lt;<span class="ty">Light</span>&gt;();
<span class="kw">if</span> (light != <span class="kw">null</span>) { ... }</pre>
</div>
<div class="point point-red">
<div class="label">중요 — Unity 오브젝트와 ??</div>
<p>Unity의 <code>UnityEngine.Object</code><code>==</code> 연산자를 오버로드해서 Destroyed 상태를 null처럼 처리한다. 하지만 C#의 <code>??</code>는 CLR 레벨의 null만 체크하므로 Destroyed 오브젝트를 null로 인식하지 못한다. → Unity 오브젝트엔 <code>??</code> 대신 <code>if (x != null)</code> 사용.</p>
</div>
</section>
<!-- ───────────────────────────────── 퀴즈 ── -->
<section id="quiz">
<h2>셀프 퀴즈</h2>
<div class="quiz">
<div class="q">Q1. Beat Saber의 _time=8, BPM=120일 때 실제 재생 시간(초)은?</div>
<button onclick="this.nextElementSibling.style.display='block'">답 보기</button>
<div class="answer">8 * 60 / 120 = <strong>4.0초</strong>. 공식: time(초) = _time(비트) × 60 / BPM</div>
</div>
<div class="quiz">
<div class="q">Q2. travelTimeOverride가 필요한 이유는? 일반 노트에도 영향을 주는가?</div>
<button onclick="this.nextElementSibling.style.display='block'">답 보기</button>
<div class="answer">
forEach로 동시 노트를 처리할 때 첫 노트와 두 번째 노트 사이에 1프레임(~16ms) 차이가 생긴다.
각 노트를 스폰하는 시점의 실제 남은 시간을 travelTime으로 주입하면 두 노트가 히트존에 동시 도착한다.
일반 노트는 스폰 시점의 remaining ≈ TargetTravelTime(1.8s)이므로 사실상 동일하다.
</div>
</div>
<div class="quiz">
<div class="q">Q3. SongSelectManager의 foreach 클로저 버그를 설명하고 해결책은?</div>
<button onclick="this.nextElementSibling.style.display='block'">답 보기</button>
<div class="answer">
람다가 foreach 루프 변수를 캡처하면, 루프 완료 후 람다 실행 시점에 변수는 마지막 값을 가리킨다.
<code>SongInfo captured = song;</code>으로 로컬 복사본을 만들면 각 람다가 독립적인 값을 캡처한다.
</div>
</div>
<div class="quiz">
<div class="q">Q4. NasPublisher가 Unity의 기본 UnityWebRequest.Post(form) 대신 수동 multipart를 쓰는 이유는?</div>
<button onclick="this.nextElementSibling.style.display='block'">답 보기</button>
<div class="answer">
Synology DSM이 Unity 기본 multipart 포맷을 거부하며 401 오류를 반환하기 때문이다.
GUID boundary, CRLF 줄바꿈, Content-Type 헤더를 직접 구성한 UploadHandlerRaw로 DSM 요구 형식을 맞춘다.
</div>
</div>
<div class="quiz">
<div class="q">Q5. static class GameSession의 한계는? 어떤 상황에서 문제가 생길 수 있나?</div>
<button onclick="this.nextElementSibling.style.display='block'">답 보기</button>
<div class="answer">
앱 프로세스가 살아있는 동안만 유지된다. 앱을 강제 종료했다가 재시작하면 값이 사라진다.
또한 Game 씬을 에디터에서 직접 Play하면 GameSession이 null이라 오류가 나 — 항상 SongSelect 씬부터 시작해야 한다.
</div>
</div>
</section>
<!-- ───────────────────────────────── 남은 작업 ── -->
<section id="todo">
<h2>남은 작업</h2>
<ul class="checklist">
<li class="done">SongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인</li>
<li class="done">SongSelect 씬 — 카드 목록 + 다운로드 + 플레이</li>
<li class="done">Game 씬 — SongController, 카운트다운, 큐브 스폰</li>
<li class="done">travelTimeOverride — 동시 노트 도착 타이밍 보정</li>
<li class="done">AudioManager — DSP 기준 PlayScheduled 싱크 개선</li>
<li class="done">VRPointerController/Setup — VR UI hover/click 처리</li>
<li class="done">GameOver Back/Retry 버튼 스크립트 참조 복구</li>
<li class="done">큐브 가로 간격 보정 — 인접 라인 겹침 방지</li>
<li class="done">C# 빌드 경고 0개 정리</li>
<li class="done">Git remote 설정 및 master/main 최신화</li>
<li class="todo">Quest 실기에서 GameOver Back/Retry 클릭 확인</li>
<li class="todo">Quest 실기에서 SongCreator UI 클릭 확인</li>
<li class="todo">큐브 간격, 세이버 각도, targetTravelTime 1.8 체감 조정</li>
<li class="todo">SongCreator 생성 직후 첫 재생 싱크/로드 로그 추가 검증</li>
</ul>
</section>
</div>
<script>
// 사이드바 active 하이라이트
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('#sidebar a');
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === '#' + e.target.id);
});
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
sections.forEach(s => io.observe(s));
</script>
</body>
</html>