프로젝트 개요

유저가 MP3 파일이나 URL을 올리면 Beat Sage AI가 비트맵을 자동 생성하고, Synology NAS에 저장한 뒤 Quest VR에서 플레이하는 시스템이다.

SongCreator 씬

MP3/URL 입력 → Beat Sage API → NAS 업로드. 곡을 시스템에 등록하는 파이프라인.

SongSelect 씬

NAS에서 songs.json 로드 → 카드 목록 표시 → 다운로드/플레이 선택.

Game 씬

캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.

아키텍처 & 데이터 흐름

전체 파이프라인

SongCreator
BeatSageUploader
AI 비트맵 생성
BeatSageConverter
.dat → NoteData
NasPublisher
NAS 업로드
songs.json 갱신
SongSelect
DownloadManager
NAS → 로컬 캐시
GameSession
씬 간 선택 전달
Game 씬
SongController
로드 + 스폰

캐시 경로 규칙

Cache Layout Application.temporaryCachePath
// DownloadManager, SongController 공통 경로 — 항상 일치해야 함
Application.temporaryCachePath/beatsaber/
  {songId}/
    {songId}.mp3             ← 오디오
    Map_{songId}_normal.json ← 난이도별 맵 JSON
    Map_{songId}_hard.json
    Map_{songId}_expert.json
    Map_{songId}_expertplus.json
주의

SongControllerDownloadManager가 각자 CacheRoot 프로퍼티를 중복 선언한다. 경로가 달라지면 파일을 못 찾으므로 공용 상수로 리팩토링하면 좋다.

스크립트 의존 관계

스크립트의존 대상의존 방식
SongControllerGameSession, AudioManager, VR_BeatManagerstatic / FindFirstObjectByType / singleton
SongSelectManagerDownloadManager, SongDetailPanel, SongLibrarySerializeField / singleton
NasPublisherBeatSageConverterstatic class 직접 호출
BeatSageUploaderBeatSageConverter, NoteDatastatic class 직접 호출
DownloadManagerNoteData (SongInfo)파라미터
VRPointerSetupVRPointerController, SceneManagerRuntimeInitializeOnLoadMethod / sceneLoaded
VRPointerControllerSelectable, EventSystem, XR InputDevice직접 Ray/Rect 교차 + ExecuteEvents

NoteData.cs — 데이터 모델 계층

프로젝트의 모든 데이터 구조가 한 파일에 정의되어 있다. Unity의 JsonUtility와 호환되도록 [Serializable] 속성이 붙어 있다.

NoteData.cs
public class NoteData
{
    public float time;          // 초 단위 (비트 → 초 변환 후)
    public int   position;     // 열 0-3
    public int   lineLayer;    // 행 0-2
    public int   colorType;    // 0=빨강, 1=파랑
    public int   cutDirection; // 0-8 (Beat Saber 스펙)
}

public class DifficultyMap
{
    public DifficultyInfo normal;
    public DifficultyInfo hard;
    public DifficultyInfo expert;
    public DifficultyInfo expertplus;

    public DifficultyInfo Get(string key) => key switch   // C# 8 switch expression
    {
        "normal"     => normal,
        "hard"       => hard,
        "expert"     => expert,
        "expertplus" => expertplus,
        _            => null
    };
}
학습 포인트

C# 8 switch expression: 전통적인 switch문 대신 람다처럼 쓸 수 있는 표현식. _는 default 케이스다. 반환값이 있는 간단한 매핑에 적합.

설계 트레이드오프

DifficultyMap은 필드 4개가 하드코딩되어 있다. 난이도가 추가되면 클래스를 수정해야 한다. Dictionary<string, DifficultyInfo>로 바꾸면 유연하지만 JsonUtility가 Dictionary를 직렬화하지 못하므로 Newtonsoft.Json이 필요하다.

GameSession.cs — 씬 간 데이터 전달

GameSession.cs
// static container — 씬이 바뀌어도 메모리에 남는다
public static class GameSession
{
    public static SongInfo SelectedSong;
    public static string   SelectedDifficulty;
}
학습 포인트 — static class란?

인스턴스를 생성할 수 없고, 모든 멤버가 자동으로 static이다. MonoBehaviour를 상속하지 않으므로 씬이 바뀌어도 값이 유지된다. Unity에서 씬 간 데이터를 넘기는 가장 단순한 방법.

대안 비교

DontDestroyOnLoad: MonoBehaviour 기반, 씬 전환 후에도 GameObject 유지.
ScriptableObject: Inspector에서 확인 가능, 에디터 재시작 전까지 유지.
PlayerPrefs: 앱 재시작 후에도 유지 (영구 저장).
static class: 가장 단순. 앱 종료하면 사라짐. 이 프로젝트엔 충분.

BeatSageConverter.cs — 포맷 변환

Beat Saber의 .dat JSON 포맷을 우리 내부 NoteData로 변환하는 순수 로직 클래스다.

핵심 변환: 비트 → 초

BeatSageConverter.cs — Convert()
// Beat Saber의 _time은 "비트 단위"
// 실제 시간(초) = 비트 / BPM * 60
time = (note._time * 60f) / bpm,

// 예: BPM=120, _time=4 → 4/120*60 = 2.0초

// 폭탄(type=3), 장애물 등은 스킵
if (note._type != 0 && note._type != 1) continue;
학습 포인트 — static class 사용 이유

상태(필드)가 전혀 없는 순수 함수들의 모음이다. 인스턴스를 만들 필요가 없으므로 static class가 적합. C#의 유틸리티 클래스 패턴.

BPM 자동 감지 (info.dat)

BeatSageConverter.cs — ParseInfoDat()
public static SongMetadata ParseInfoDat(string json)
{
    var info = JsonUtility.FromJson<BeatSageInfoDat>(json);
    if (info == null) return null;
    return new SongMetadata
    {
        title  = (info._songName       ?? "").Trim(),   // null 병합 연산자
        artist = (info._songAuthorName ?? "").Trim(),
        bpm    = info._beatsPerMinute,
    };
}
null 병합 연산자 ??

a ?? b — a가 null이면 b를 반환. a != null ? a : b의 축약형. JSON 파싱 시 누락된 필드가 null로 들어올 때 안전하게 처리.

BeatSageUploader.cs — 외부 API 연동

Beat Sage 서버에 오디오를 올리고, 생성 완료를 폴링한 뒤, 결과 ZIP을 다운받아 변환까지 처리한다.

코루틴 체이닝 구조

BeatSageUploader.cs — Upload() 흐름
Upload() / UploadFromUrl()
  └─ CreateLevel() / CreateLevelFromUrl()   // [1/4] POST → levelId 획득
  └─ PollAndDownload()                       // 공통 phase 2~4
       ├─ PollHeartbeat() (5초 간격, 최대 300초)  // [2/4] 생성 완료 대기
       ├─ DownloadZip() (최대 3회 재시도)        // [3/4] ZIP 다운로드
       └─ ExtractAndConvert()                   // [4/4] .dat → NoteData
학습 포인트 — yield return StartCoroutine()

코루틴 안에서 다른 코루틴을 yield return StartCoroutine()으로 호출하면 내부 코루틴이 끝날 때까지 기다린다. 순차적인 비동기 작업을 async/await 없이 체이닝하는 Unity 방식.

멀티파트 폼 — URL vs 파일

BeatSageUploader.cs
// URL 업로드: audio_url 필드에 문자열
new MultipartFormDataSection("audio_url", audioUrl)

// 파일 업로드: 바이트 배열 + MIME 타입
new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg")

폴링 타임아웃 패턴

BeatSageUploader.cs — PollAndDownload()
float elapsed = 0f;
while (!ready && elapsed < POLL_TIMEOUT)   // 300초 초과 시 탈출
{
    yield return new WaitForSeconds(POLL_INTERVAL);  // 5초 대기
    elapsed += POLL_INTERVAL;
    yield return PollHeartbeat(levelId, status => {
        ready = status == "generated" || status == "done";
        error = status == "error";
    }, onError);
    onProgress?.Invoke(0.15f + Mathf.Clamp01(elapsed / POLL_TIMEOUT) * 0.6f);
}
학습 포인트 — Action 콜백 패턴

Unity 코루틴은 값을 return할 수 없다. 대신 콜백(Action)을 파라미터로 받아서 결과를 전달한다. onSuccess, onError가 대표적. ?.는 null 조건부 호출 — null이면 아무것도 안 함.

간이 JSON 파싱

BeatSageUploader.cs — ParseJsonString()
private static string ParseJsonString(string json, string key)
{
    string search = $"\"{key}\":";     // "id":"  를 찾음
    int start = json.IndexOf(search);
    if (start < 0) return null;
    start += search.Length;
    int end = json.IndexOf('"', start);  // 닫는 " 위치
    return end > start ? json.Substring(start, end - start) : null;
}
주의 — 수동 파싱의 한계

Beat Sage 응답이 간단해서 IndexOf로 파싱하지만, JSON 값에 이스케이프된 따옴표(\")가 있으면 틀린다. 복잡한 응답엔 JsonUtilityNewtonsoft.Json을 써야 한다.

NasPublisher.cs — Synology DSM API

DSM FileStation API를 통해 NAS에 파일을 업로드한다. 로그인 → 업로드 → 로그아웃 세션 흐름을 코루틴 체이닝으로 구현.

수동 multipart body 구성 — 핵심 패턴

NasPublisher.cs — UploadBytes()
// Unity 기본 UnityWebRequest.Post(form) 는 DSM에서 401 반환 → 수동 구성 필요
string boundary = Guid.NewGuid().ToString("N");   // 랜덤 경계 문자열

using var body = new MemoryStream();

// 텍스트 필드
WriteField("path",           nasFolder);
WriteField("create_parents", "true");
WriteField("overwrite",      "true");

// 파일 파트
WriteText($"--{boundary}\r\n");
WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"\r\n");
WriteText("Content-Type: application/octet-stream\r\n\r\n");
body.Write(bytes, 0, bytes.Length);
WriteText("\r\n--{boundary}--\r\n");  // 종료 경계

req.uploadHandler   = new UploadHandlerRaw(body.ToArray());
req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
학습 포인트 — multipart/form-data 구조

--boundary 로 각 파트를 구분하고, 마지막은 --boundary--로 끝낸다.
Content-Dispositionname(필드명)과 파일이면 filename을 명시.
바이너리 데이터는 MemoryStream에 직접 Write().

songs.json Patch 전략

NasPublisher.cs — PatchSongsJson()
// 기존 목록 읽기 → 없으면 새로 생성 (null 병합)
list ??= new SongsList { version = "1.0", songs = new List<SongInfo>() };

// 같은 id가 있으면 교체, 없으면 추가 — upsert 패턴
int idx = list.songs.FindIndex(s => s.id == newSong.id);
if (idx >= 0) list.songs[idx] = newSong;
else           list.songs.Add(newSong);
학습 포인트 — Upsert 패턴

DB의 INSERT OR UPDATE와 동일 개념. 리스트에서 인덱스를 찾아 있으면 교체, 없으면 추가. FindIndex는 조건 람다를 받아 인덱스를 반환하며, 없으면 -1.

DownloadManager.cs — 파일 다운로드 캐시

NAS 정적 서버에서 MP3와 맵 JSON을 로컬 캐시로 내려받는다. 오디오 70% + 맵 30% 비율로 진행률을 계산.

진행률 분할 계산

DownloadManager.cs — DownloadSongCoroutine()
// 1단계: 오디오 (전체의 0% ~ 70%)
yield return DownloadFile(audioUrl, audioPath,
    p => onProgress?.Invoke(p * 0.7f),  // 0.0 ~ 0.7
    err => { ... });

// 2단계: 맵 (전체의 70% ~ 100%)
yield return DownloadFile(mapUrl, mapPath,
    p => onProgress?.Invoke(0.7f + p * 0.3f),  // 0.7 ~ 1.0
    err => { ... });

이미 있으면 스킵

DownloadManager.cs
// 캐시 히트 → 다운로드 건너뜀 (멱등성 보장)
if (!File.Exists(audioPath))
{
    yield return DownloadFile(...);
}
학습 포인트 — 멱등성(Idempotency)

같은 작업을 여러 번 해도 결과가 동일한 성질. 이미 다운로드된 파일은 재다운로드하지 않으므로 다운로드 중 앱 종료 후 재시도해도 안전하다. 단, 오디오는 있고 맵이 없으면 오디오를 스킵하고 맵만 받는다.

주의 — 실패 시 불완전 파일

DownloadHandlerFile이 실패하면 File.Delete(savePath)로 정리한다. 그렇지 않으면 0바이트 파일이 남아 다음에 "캐시 히트"로 오판할 수 있다.

SongController.cs — Game 씬 핵심 브릿지

Game 씬이 시작되면 GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고, 카운트다운 후 큐브를 스폰한다.

전체 코루틴 흐름

Start()
LoadAndPlay()
GetAudioClip
비동기
File.ReadAllText
동기
Countdown
3,2,1,GO
SpawnRoutine
+
WaitForCompletion

travelTimeOverride — 동시 노트 보정

SongController.cs — SpawnNote()
private void SpawnNote(NoteData note)
{
    // 스폰 시점의 실제 남은 시간으로 travelTime을 역산
    // → foreach 순차 처리로 생기는 16ms 프레임 차이를 흡수
    float remaining = note.time - _audio.CurrentTime;
    float travelTime = Mathf.Max(0.05f, remaining);

    var info = new SpawnEventInfo
    {
        travelTimeOverride = travelTime,   // VRBeatsKit이 이 값을 우선 사용
        ...
    };
}

오디오 싱크 — DSP 기준 예약 재생

AudioManager.cs — PlayClipScheduled()
public double PlayClipScheduled(AudioClip clip, double delaySeconds = 0.1)
{
    audioSource.Stop();
    audioSource.clip = clip;
    audioSource.time = 0.0f;

    scheduledDspStartTime = AudioSettings.dspTime + delaySeconds;
    hasScheduledClip = true;
    audioSource.PlayScheduled(scheduledDspStartTime);

    return scheduledDspStartTime;
}

public float CurrentTime
{
    get
    {
        if (hasScheduledClip)
            return (float)(AudioSettings.dspTime - scheduledDspStartTime);
        return audioSource.time;
    }
}
개선 완료

AudioSource.Play() 대신 PlayScheduled()를 사용해 음악 시작 시점을 DSP 시간에 고정했다. 노트 스폰 기준도 AudioSettings.dspTime 차이로 계산하므로 프레임 상태에 따른 싱크 흔들림이 줄었다.

학습 포인트 — 타이밍 보정 기법

문제: BPM=120에서 동시 노트 2개가 foreach로 처리되면 1프레임(16ms) 차이가 난다.
해결: travelTime을 "설정값"이 아니라 "스폰 시점에서 목표 시간까지 남은 실제 시간"으로 주입.
노트 A를 스폰할 때 remaining=1.800, B를 스폰할 때 remaining=1.784 → 각각 다른 speed로 발사되어 히트존에 동시 도착.

cutDirection 매핑 — 조회 테이블

SongController.cs
// if-else 체인 대신 배열 인덱스로 O(1) 매핑
private static readonly Direction[] CutDirMap =
{
    Direction.Up, Direction.Down, Direction.Left, Direction.Right,
    Direction.UpperLeft, Direction.UpperRight, Direction.LowerLeft, Direction.LowerRight,
    Direction.Center,  // index 8 = Any/Dot
};

private static Direction MapCutDirection(int cut)
    => (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
학습 포인트 — 조회 테이블(Lookup Table)

switch/if-else 9개 대신 static readonly 배열 + 인덱스 접근. 컴파일 타임에 메모리를 할당하고 GC 없이 재사용. 매핑 항목이 많을수록 코드가 깔끔하고 빠르다.

위치 계산

SongController.cs
private const float LaneSpacing = 0.42f;
private const float LayerSpacing = 0.38f;
private const float HorizontalCenter = 1.5f;
private const float VerticalCenter = 1f;

private static float MapLaneX(int position)
{
    int lane = Mathf.Clamp(position, 0, 3);
    return (lane - HorizontalCenter) * LaneSpacing;
}

private static float MapLayerY(int lineLayer)
{
    int layer = Mathf.Clamp(lineLayer, 0, 2);
    return (layer - VerticalCenter) * LayerSpacing;
}
개선 완료 — 가로 겹침

기존 라인 간격은 0.25였고 큐브 실제 폭은 약 0.36이라 인접 라인이 겹칠 수밖에 없었다. 현재 X 좌표는 대략 -0.63, -0.21, 0.21, 0.63으로 벌어져 가로 겹침을 피한다.

VRPointerController / VRPointerSetup — VR UI 클릭 안정화

게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.

구조

VRPointerSetup
BeforeSceneLoad 자동 생성
SceneManager.sceneLoaded
Controller/Hand + LineRenderer 탐색
VRPointerController 주입
VRPointerController.cs — 클릭 처리
private static void Click(Selectable sel)
{
    var es = EventSystem.current;
    var eventData = new PointerEventData(es);

    ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerDownHandler);
    ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerUpHandler);
    ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerClickHandler);

    var btn = sel.GetComponent<Button>();
    if (btn != null) btn.onClick.Invoke();
}
평가

XR UI 모듈 설정에 의존하지 않고 Selectable을 직접 Ray/Rect 교차 검사하는 방식이라 씬별 Raycaster 설정 차이에 강하다. 단, 커스텀 Selectable이나 비사각형 UI가 늘어나면 GraphicRaycaster 기반으로 재검토할 수 있다.

실기 확인 필요

Game 씬에서는 포인터가 기본 비활성이고 게임오버 시 VR_InteractorController를 통해 활성화된다. Quest에서 Back/Retry, SongCreator 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.

SongSelectManager.cs — 동적 UI 생성

Inspector에서 프리팹을 쓰지 않고 코드로 직접 GameObject를 생성해 카드 리스트를 만든다.

카드 생성 패턴

SongSelectManager.cs — SpawnCard()
var card = new GameObject(song.title);
card.transform.SetParent(cardContainer, worldPositionStays: false);  // false = 로컬 유지

var bg  = card.AddComponent<Image>();
var btn = card.AddComponent<Button>();

// 클로저 캡처 버그 방지 — foreach 루프 변수를 로컬에 복사
SongInfo captured = song;
btn.onClick.AddListener(() => OnCardClicked(captured));
중요 — 클로저 캡처 버그

foreach에서 람다로 루프 변수를 캡처하면, 루프가 끝난 뒤 모든 버튼이 마지막 곡을 가리킨다.
SongInfo captured = song; 으로 로컬 복사본을 만들어야 각 클로저가 자신의 값을 가진다.
C# 5 이후 foreach는 수정됐지만, for(int i=0; ...)에선 여전히 발생하므로 습관적으로 복사하는 게 좋다.

마퀴 스크롤 구조

SongSelectManager.cs — 마퀴 레이아웃
card
  └─ TitleMask  (RectMask2D)    ← 클리핑 컨테이너
       └─ Title (TMP_Text + MarqueeText)  ← 텍스트가 마스크 밖으로 스크롤
  └─ Artist (TMP_Text)
  └─ Badge  (Image + TMP_Text)  ← 다운로드된 곡에만 표시
학습 포인트 — RectMask2D

Mask 컴포넌트와 달리 스텐실 버퍼를 쓰지 않아 오버헤드가 적다. RectTransform 영역 밖의 자식을 잘라낸다. 텍스트가 컨테이너 너비를 넘어도 잘리고, 자식이 스크롤하면 마퀴처럼 보인다.

오프라인 폴백 (캐시)

SongSelectManager.cs — FetchSongs()
downloadManager.FetchSongsList(
    onSuccess: list => {
        SaveCache(list);     // 성공하면 캐시 갱신
        RefreshCards();
    },
    onError: _ => {
        SongsList cached = LoadCache();  // 실패하면 캐시로 폴백
        if (cached != null) RefreshCards();
        else errorOverlay.SetActive(true);  // 캐시도 없으면 에러 표시
    });

디자인 패턴 정리

패턴사용 위치설명
Coroutine Chaining BeatSageUploader, SongController, NasPublisher

yield return StartCoroutine()으로 비동기 작업을 순차 실행. async/await 대신 Unity 방식.

Callback (Action) DownloadManager, BeatSageUploader, NasPublisher

onSuccess / onError / onProgress 콜백으로 코루틴 결과 전달.

Static Class (전역 상태) GameSession, BeatSageConverter, SongLibrary

GameSession은 씬 간 데이터 전달, BeatSageConverter는 순수 유틸리티.

Lookup Table SongController.CutDirMap, BeatSageUploader.DiffNames

static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.

Deterministic Sort SongController.CompareNotes()

time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.

Runtime Injection VRPointerSetup

씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.

Upsert NasPublisher.PatchSongsJson()

FindIndex로 기존 항목 교체, 없으면 추가. DB의 INSERT OR REPLACE와 동일.

Offline Fallback SongSelectManager.FetchSongs()

네트워크 실패 시 로컬 캐시 JSON을 사용. 오프라인에서도 목록 표시 가능.

Idempotent Cache DownloadManager.DownloadSongCoroutine()

파일 존재 여부 확인 후 스킵. 중복 다운로드 없음.

Unity 핵심 팁 (이 프로젝트에서 배우는 것)

1. UnityWebRequest 패턴

일반 GET
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { /* 에러 처리 */ }
string text = req.downloadHandler.text;
오디오 클립 로드
using var req = UnityWebRequestMultimedia.GetAudioClip("file://" + path, AudioType.MPEG);
yield return req.SendWebRequest();
AudioClip clip = DownloadHandlerAudioClip.GetContent(req);

2. WaitUntil — 조건 기반 대기

SongController.cs
// 조건이 true가 될 때까지 매 프레임 체크
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);

3. LayoutRebuilder — 동적 UI 갱신

SongSelectManager.cs
// 자식을 코드로 추가한 뒤 레이아웃 강제 재계산
LayoutRebuilder.ForceRebuildLayoutImmediate(cardContainer);
Canvas.ForceUpdateCanvases();

4. using 선언 (C# 8)

C# 8 using 선언
// 블록 없이 — 변수가 선언된 스코프 끝에서 자동 Dispose
using var req = UnityWebRequest.Get(url);
using var ms  = new MemoryStream(bytes);

// 전통 방식 (C# 7 이하)
using (var req = UnityWebRequest.Get(url)) { ... }

5. Unity 6 API 전환

deprecated API 정리
// 이전
FindObjectOfType<AudioManager>();
FindObjectsOfType<Canvas>();
tTmp.enableWordWrapping = false;

// 현재
FindFirstObjectByType<AudioManager>();
FindObjectsByType<Canvas>(FindObjectsSortMode.None);
tTmp.textWrappingMode = TextWrappingModes.NoWrap;
현재 상태

dotnet build VRBeatSaber.slnx --no-incremental 기준 경고 0개, 오류 0개다. VRBeatsKit 내부 deprecated API와 미사용 필드 경고까지 정리되어 있다.

6. Unity ?? 연산자 주의사항

SaberGlow.cs 버그 사례
// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함
var light = _pointLight ?? GetComponent<Light>();  // ← 위험!

// 수정: 명시적 GetComponent만 사용
var light = GetComponent<Light>();
if (light != null) { ... }
중요 — Unity 오브젝트와 ??

Unity의 UnityEngine.Object== 연산자를 오버로드해서 Destroyed 상태를 null처럼 처리한다. 하지만 C#의 ??는 CLR 레벨의 null만 체크하므로 Destroyed 오브젝트를 null로 인식하지 못한다. → Unity 오브젝트엔 ?? 대신 if (x != null) 사용.

셀프 퀴즈

Q1. Beat Saber의 _time=8, BPM=120일 때 실제 재생 시간(초)은?
8 * 60 / 120 = 4.0초. 공식: time(초) = _time(비트) × 60 / BPM
Q2. travelTimeOverride가 필요한 이유는? 일반 노트에도 영향을 주는가?
forEach로 동시 노트를 처리할 때 첫 노트와 두 번째 노트 사이에 1프레임(~16ms) 차이가 생긴다. 각 노트를 스폰하는 시점의 실제 남은 시간을 travelTime으로 주입하면 두 노트가 히트존에 동시 도착한다. 일반 노트는 스폰 시점의 remaining ≈ TargetTravelTime(1.8s)이므로 사실상 동일하다.
Q3. SongSelectManager의 foreach 클로저 버그를 설명하고 해결책은?
람다가 foreach 루프 변수를 캡처하면, 루프 완료 후 람다 실행 시점에 변수는 마지막 값을 가리킨다. SongInfo captured = song;으로 로컬 복사본을 만들면 각 람다가 독립적인 값을 캡처한다.
Q4. NasPublisher가 Unity의 기본 UnityWebRequest.Post(form) 대신 수동 multipart를 쓰는 이유는?
Synology DSM이 Unity 기본 multipart 포맷을 거부하며 401 오류를 반환하기 때문이다. GUID boundary, CRLF 줄바꿈, Content-Type 헤더를 직접 구성한 UploadHandlerRaw로 DSM 요구 형식을 맞춘다.
Q5. static class GameSession의 한계는? 어떤 상황에서 문제가 생길 수 있나?
앱 프로세스가 살아있는 동안만 유지된다. 앱을 강제 종료했다가 재시작하면 값이 사라진다. 또한 Game 씬을 에디터에서 직접 Play하면 GameSession이 null이라 오류가 나 — 항상 SongSelect 씬부터 시작해야 한다.

남은 작업