58838f0acb
학습용 HTML 2개 추가 — 전체 15개 스크립트 줄 단위 주석본(annotated_code.html)과 설계 분석/퀴즈 문서(code_review.html). LiberationSans SDF Fallback 에셋 교체. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1043 lines
51 KiB
HTML
1043 lines
51 KiB
HTML
<!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>
|
||
|
||
<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 + Meta Quest 기반 커스텀 비트 세이버 클론 | 학습용 코드 리뷰 문서</p>
|
||
<div style="margin-top:12px">
|
||
<span class="badge badge-blue">Unity 2022+</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>
|
||
</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 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.</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 / FindObjectOfType / 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>
|
||
</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<string, DifficultyInfo></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><<span class="ty">BeatSageInfoDat</span>>(json);
|
||
<span class="kw">if</span> (info == <span class="kw">null</span>) <span class="kw">return null</span>;
|
||
<span class="kw">return new</span> <span class="ty">SongMetadata</span>
|
||
{
|
||
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><<span class="ty">SongInfo</span>>() };
|
||
|
||
<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>
|
||
|
||
<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="cmt">// Beat Saber 그리드 → 월드 좌표 선형 매핑</span>
|
||
<span class="ty">float</span> x = <span class="num">-0.375f</span> + note.position * <span class="num">0.25f</span>; <span class="cmt">// 열 0→-0.375, 1→-0.125, 2→0.125, 3→0.375</span>
|
||
<span class="ty">float</span> y = <span class="num">-0.333f</span> + note.lineLayer * <span class="num">0.333f</span>; <span class="cmt">// 행 0→-0.333, 1→0, 2→0.333</span></pre>
|
||
</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><<span class="ty">Image</span>>();
|
||
<span class="kw">var</span> btn = card.<span class="fn">AddComponent</span><<span class="ty">Button</span>>();
|
||
|
||
<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>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 ?? 연산자 주의사항</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><<span class="ty">Light</span>>(); <span class="cmt">// ← 위험!</span>
|
||
|
||
<span class="cmt">// 수정: 명시적 GetComponent만 사용</span>
|
||
<span class="kw">var</span> light = <span class="fn">GetComponent</span><<span class="ty">Light</span>>();
|
||
<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">Git remote 설정 (Synology NAS)</li>
|
||
<li class="todo">Game 씬 ScoreManager / ScoreHUD 연결</li>
|
||
<li class="todo">Game 씬 ResultsPanel 연결 (CLEAR/FAILED, 랭크)</li>
|
||
<li class="todo">VR 기기 실제 플레이 테스트</li>
|
||
<li class="todo">targetTravelTime 1.8 플레이 후 미세 조정</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>
|