c335995a9a
- SongSelectManager/SongDetailPanel: 곡 선택 및 상세 패널 개선 - SongCreatorManager: 곡 생성 기능 추가 - FinalScoreLabel/ScoreManager: 결과 화면 점수 UI 업데이트 - MarqueeText: 마퀴 텍스트 컴포넌트 개선 - NoteData/SongController: 노트 데이터 및 컨트롤러 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
7.9 KiB
C#
235 lines
7.9 KiB
C#
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 const float LaneSpacing = 0.42f;
|
|
private const float LayerSpacing = 0.34f;
|
|
private const float HorizontalCenter = 1.5f;
|
|
private const float VerticalCenter = 1f;
|
|
private const float VerticalOffset = 0.22f;
|
|
|
|
private AudioManager _audio;
|
|
private ScoreManager _scoreManager;
|
|
private float _clipLength;
|
|
|
|
private static string CacheRoot =>
|
|
Path.Combine(Application.persistentDataPath, "beatsaber");
|
|
|
|
private void Start()
|
|
{
|
|
_audio = FindFirstObjectByType<AudioManager>();
|
|
_scoreManager = FindFirstObjectByType<ScoreManager>();
|
|
StartCoroutine(LoadAndPlay());
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
|
|
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
|
|
}
|
|
|
|
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);
|
|
}
|
|
_clipLength = clip.length;
|
|
|
|
// 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 == null)
|
|
{
|
|
Debug.LogError("[SongController] Map parse failed");
|
|
yield break;
|
|
}
|
|
if (map.target == null)
|
|
map.target = new List<NoteData>();
|
|
|
|
if (IsForcedResultMap(map))
|
|
{
|
|
_scoreManager?.SetTotalNotes(Mathf.Max(0, map.forcedResult.totalNotes));
|
|
|
|
yield return StartCoroutine(Countdown());
|
|
|
|
_audio.PlayClip(clip);
|
|
yield return new WaitForSeconds(Mathf.Min(Mathf.Max(0.2f, _clipLength), 0.75f));
|
|
|
|
_scoreManager?.ApplyForcedResult(
|
|
map.forcedResult.totalNotes,
|
|
map.forcedResult.perfect,
|
|
map.forcedResult.great,
|
|
map.forcedResult.good,
|
|
map.forcedResult.miss,
|
|
map.forcedResult.maxCombo);
|
|
_scoreManager?.CompleteSong();
|
|
onLevelComplete?.Invoke();
|
|
yield break;
|
|
}
|
|
|
|
map.target.Sort(CompareNotes);
|
|
if (_clipLength <= 0.0f)
|
|
{
|
|
float lastNoteTime = map.target.Count > 0 ? map.target[map.target.Count - 1].time : 0.0f;
|
|
_clipLength = Mathf.Max(song.duration, lastNoteTime + 1.0f);
|
|
}
|
|
_scoreManager?.SetTotalNotes(map.target.Count);
|
|
|
|
yield return StartCoroutine(Countdown());
|
|
|
|
_audio.PlayClip(clip);
|
|
|
|
StartCoroutine(SpawnRoutine(map.target));
|
|
yield return StartCoroutine(WaitForCompletion(_clipLength, map.target));
|
|
}
|
|
|
|
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 adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
|
|
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
|
|
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
|
|
SpawnNote(note);
|
|
}
|
|
}
|
|
|
|
private void SpawnNote(NoteData note)
|
|
{
|
|
float x = MapLaneX(note.position);
|
|
float y = MapLayerY(note.lineLayer);
|
|
|
|
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
|
|
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _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 = false,
|
|
speed = 2f,
|
|
travelTimeOverride = travelTime,
|
|
};
|
|
|
|
VR_BeatManager.instance.Spawn(cubePrefab, info);
|
|
}
|
|
|
|
private static int CompareNotes(NoteData a, NoteData b)
|
|
{
|
|
int timeCompare = a.time.CompareTo(b.time);
|
|
if (timeCompare != 0)
|
|
return timeCompare;
|
|
|
|
int positionCompare = a.position.CompareTo(b.position);
|
|
if (positionCompare != 0)
|
|
return positionCompare;
|
|
|
|
return a.lineLayer.CompareTo(b.lineLayer);
|
|
}
|
|
|
|
private static bool IsForcedResultMap(MapData map)
|
|
=> map?.forcedResult != null && map.forcedResult.enabled;
|
|
|
|
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 VerticalOffset + (layer - VerticalCenter) * LayerSpacing;
|
|
}
|
|
|
|
// 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, List<NoteData> notes)
|
|
{
|
|
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
|
|
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
|
|
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
|
|
yield return new WaitForSeconds(0.35f);
|
|
_scoreManager?.CompleteSong();
|
|
onLevelComplete?.Invoke();
|
|
}
|
|
}
|