feat: Game scene — SongController bridges custom map to VRBeatsKit
- SongController: loads MP3 + Beat Saber JSON map, runs countdown (3→2→1→GO), spawns cubes via VR_BeatManager.Spawn() synced to audioSource.time - NoteData → SpawnEventInfo mapping: position/lineLayer → x/y, colorType → ColorSide, cutDirection → Direction enum - travelTimeOverride on SpawnEventInfo: each cube's travel time is back-calculated from remaining time at spawn moment, so simultaneous notes arrive at hit zone together regardless of frame-level spawn delay - AudioManager: add PlayClip(AudioClip) and CurrentTime property - VR_BeatManager: respect travelTimeOverride when non-zero - Settings.asset: targetTravelTime 0.5 → 1.8 for natural Beat Saber approach feel - SceneBuilder ④: auto-builds Game.unity from SaberStyle, wires SongController refs, registers in Build Settings - LiberationSans SDF fallback updated with NanumGothic for Korean text support - Remove unused SampleScene Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using VRBeats;
|
||||
using VRBeats.ScriptableEvents;
|
||||
|
||||
public class SongController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Spawneable cubePrefab;
|
||||
[SerializeField] private GameEvent onLevelComplete;
|
||||
[SerializeField] private TMP_Text countdownText;
|
||||
|
||||
private AudioManager _audio;
|
||||
|
||||
private static string CacheRoot =>
|
||||
Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_audio = FindObjectOfType<AudioManager>();
|
||||
StartCoroutine(LoadAndPlay());
|
||||
}
|
||||
|
||||
private IEnumerator LoadAndPlay()
|
||||
{
|
||||
SongInfo song = GameSession.SelectedSong;
|
||||
string diff = GameSession.SelectedDifficulty;
|
||||
|
||||
if (song == null || string.IsNullOrEmpty(diff))
|
||||
{
|
||||
Debug.LogError("[SongController] No song/difficulty selected");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Load audio clip from local cache
|
||||
string audioPath = Path.Combine(CacheRoot, song.id, song.id + ".mp3");
|
||||
AudioClip clip;
|
||||
using (var req = UnityWebRequestMultimedia.GetAudioClip("file://" + audioPath, AudioType.MPEG))
|
||||
{
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogError($"[SongController] Audio load failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
clip = DownloadHandlerAudioClip.GetContent(req);
|
||||
}
|
||||
|
||||
// Load and parse map
|
||||
DifficultyInfo diffInfo = song.difficulties.Get(diff);
|
||||
if (diffInfo == null)
|
||||
{
|
||||
Debug.LogError($"[SongController] Difficulty '{diff}' not found");
|
||||
yield break;
|
||||
}
|
||||
string mapPath = Path.Combine(CacheRoot, song.id, Path.GetFileName(diffInfo.mapFile));
|
||||
if (!File.Exists(mapPath))
|
||||
{
|
||||
Debug.LogError($"[SongController] Map file missing: {mapPath}");
|
||||
yield break;
|
||||
}
|
||||
MapData map = JsonUtility.FromJson<MapData>(File.ReadAllText(mapPath));
|
||||
if (map?.target == null)
|
||||
{
|
||||
Debug.LogError("[SongController] Map parse failed");
|
||||
yield break;
|
||||
}
|
||||
map.target.Sort((a, b) => a.time.CompareTo(b.time));
|
||||
|
||||
yield return StartCoroutine(Countdown());
|
||||
|
||||
_audio.PlayClip(clip);
|
||||
|
||||
StartCoroutine(SpawnRoutine(map.target));
|
||||
yield return StartCoroutine(WaitForCompletion(clip.length));
|
||||
}
|
||||
|
||||
private IEnumerator Countdown()
|
||||
{
|
||||
if (countdownText == null) yield break;
|
||||
countdownText.gameObject.SetActive(true);
|
||||
|
||||
string[] labels = { "3", "2", "1", "GO!" };
|
||||
float[] durations = { 1f, 1f, 1f, 0.6f };
|
||||
|
||||
for (int i = 0; i < labels.Length; i++)
|
||||
{
|
||||
countdownText.text = labels[i];
|
||||
yield return new WaitForSeconds(durations[i]);
|
||||
}
|
||||
|
||||
countdownText.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private IEnumerator SpawnRoutine(List<NoteData> notes)
|
||||
{
|
||||
float travelTime = VR_BeatManager.instance.GameSettings.TargetTravelTime;
|
||||
|
||||
foreach (NoteData note in notes)
|
||||
{
|
||||
float spawnAt = Mathf.Max(0f, note.time - travelTime);
|
||||
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
|
||||
SpawnNote(note);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnNote(NoteData note)
|
||||
{
|
||||
float x = -0.375f + note.position * 0.25f;
|
||||
float y = -0.333f + note.lineLayer * 0.333f;
|
||||
|
||||
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
|
||||
float remaining = note.time - _audio.CurrentTime;
|
||||
float travelTime = Mathf.Max(0.05f, remaining);
|
||||
|
||||
var info = new SpawnEventInfo
|
||||
{
|
||||
position = new Vector3(x, y, 0f),
|
||||
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
|
||||
hitDirection = MapCutDirection(note.cutDirection),
|
||||
useSpark = true,
|
||||
speed = 2f,
|
||||
travelTimeOverride = travelTime,
|
||||
};
|
||||
|
||||
VR_BeatManager.instance.Spawn(cubePrefab, info);
|
||||
}
|
||||
|
||||
// Beat Saber cutDirection → VRBeats Direction
|
||||
// BS: 0=Up 1=Down 2=Left 3=Right 4=UpperLeft 5=UpperRight 6=LowerLeft 7=LowerRight 8=Any
|
||||
private static readonly Direction[] CutDirMap =
|
||||
{
|
||||
Direction.Up,
|
||||
Direction.Down,
|
||||
Direction.Left,
|
||||
Direction.Right,
|
||||
Direction.UpperLeft,
|
||||
Direction.UpperRight,
|
||||
Direction.LowerLeft,
|
||||
Direction.LowerRight,
|
||||
Direction.Center,
|
||||
};
|
||||
|
||||
private static Direction MapCutDirection(int cut)
|
||||
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
|
||||
|
||||
private IEnumerator WaitForCompletion(float clipLength)
|
||||
{
|
||||
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
|
||||
onLevelComplete?.Invoke();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user