feat: polish VR gameplay and sync tools
This commit is contained in:
@@ -36,6 +36,8 @@ public class BeatSageNote
|
||||
|
||||
public static class BeatSageConverter
|
||||
{
|
||||
private static readonly bool LogConversions = false;
|
||||
|
||||
public static List<NoteData> Convert(string rawJson, float bpm)
|
||||
{
|
||||
var result = new List<NoteData>();
|
||||
@@ -62,7 +64,8 @@ public static class BeatSageConverter
|
||||
});
|
||||
}
|
||||
|
||||
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
|
||||
if (LogConversions)
|
||||
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
|
||||
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
|
||||
Directory.Delete(dir, recursive: true);
|
||||
Debug.Log($"[DownloadManager] 삭제: {songId}");
|
||||
}
|
||||
|
||||
string legacyDir = LegacySongDir(songId);
|
||||
if (Directory.Exists(legacyDir))
|
||||
Directory.Delete(legacyDir, recursive: true);
|
||||
}
|
||||
|
||||
public void DeleteDifficulty(SongInfo song, string difficulty)
|
||||
{
|
||||
TryMigrateLegacySong(song.id);
|
||||
|
||||
string path = MapPath(song, difficulty);
|
||||
if (path != null && File.Exists(path))
|
||||
File.Delete(path);
|
||||
|
||||
string songDir = SongDir(song.id);
|
||||
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
|
||||
Directory.Delete(songDir);
|
||||
}
|
||||
|
||||
public bool IsSongDownloaded(string songId)
|
||||
=> File.Exists(AudioPath(songId));
|
||||
{
|
||||
TryMigrateLegacySong(songId);
|
||||
return File.Exists(AudioPath(songId));
|
||||
}
|
||||
|
||||
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
|
||||
{
|
||||
TryMigrateLegacySong(song.id);
|
||||
|
||||
string path = MapPath(song, difficulty);
|
||||
return path != null && File.Exists(path);
|
||||
}
|
||||
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
|
||||
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
||||
Action<float> onProgress, Action onComplete, Action<string> onError)
|
||||
{
|
||||
TryMigrateLegacySong(song.id);
|
||||
|
||||
string songDir = Path.GetFullPath(SongDir(song.id));
|
||||
Directory.CreateDirectory(songDir);
|
||||
|
||||
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
|
||||
|
||||
private static string SongDir(string songId)
|
||||
=> Path.Combine(CacheRoot, songId);
|
||||
|
||||
private static string LegacySongDir(string songId)
|
||||
=> Path.Combine(LegacyCacheRoot, songId);
|
||||
|
||||
private static void TryMigrateLegacySong(string songId)
|
||||
{
|
||||
string sourceDir = LegacySongDir(songId);
|
||||
string targetDir = SongDir(songId);
|
||||
|
||||
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
|
||||
return;
|
||||
|
||||
CopyDirectory(sourceDir, targetDir);
|
||||
Directory.Delete(sourceDir, recursive: true);
|
||||
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string targetDir)
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
foreach (string file in Directory.GetFiles(sourceDir))
|
||||
{
|
||||
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
|
||||
File.Copy(file, targetFile, overwrite: true);
|
||||
}
|
||||
|
||||
foreach (string dir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
|
||||
CopyDirectory(dir, targetSubDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Video;
|
||||
|
||||
public class Game360VideoBackground : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private VideoClip videoClip;
|
||||
[SerializeField] private int renderTextureSize = 2048;
|
||||
[SerializeField] private bool muteVideoAudio = true;
|
||||
[SerializeField, Range(0f, 360f)] private float skyboxRotationDegrees = 0f;
|
||||
[SerializeField, Range(0f, 8f)] private float skyboxExposure = 1f;
|
||||
|
||||
private GameObject videoPlayerObject;
|
||||
private Material skyboxMaterial;
|
||||
private Material previousSkybox;
|
||||
private RenderTexture renderTexture;
|
||||
private VideoPlayer videoPlayer;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (videoClip == null)
|
||||
{
|
||||
Debug.LogWarning("[Game360VideoBackground] videoClip is not assigned.");
|
||||
return;
|
||||
}
|
||||
|
||||
CreateSkyboxMaterial();
|
||||
CreateVideoPlayer();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (videoPlayer != null)
|
||||
{
|
||||
videoPlayer.prepareCompleted -= OnVideoPrepared;
|
||||
videoPlayer.errorReceived -= OnVideoError;
|
||||
}
|
||||
|
||||
if (renderTexture != null)
|
||||
{
|
||||
renderTexture.Release();
|
||||
Destroy(renderTexture);
|
||||
}
|
||||
|
||||
RenderSettings.skybox = previousSkybox;
|
||||
DynamicGI.UpdateEnvironment();
|
||||
|
||||
if (skyboxMaterial != null)
|
||||
Destroy(skyboxMaterial);
|
||||
|
||||
if (videoPlayerObject != null)
|
||||
Destroy(videoPlayerObject);
|
||||
}
|
||||
|
||||
private void CreateSkyboxMaterial()
|
||||
{
|
||||
renderTexture = new RenderTexture(renderTextureSize, renderTextureSize / 2, 0, RenderTextureFormat.ARGB32)
|
||||
{
|
||||
name = "Game360VideoRenderTexture",
|
||||
wrapMode = TextureWrapMode.Clamp,
|
||||
filterMode = FilterMode.Bilinear,
|
||||
};
|
||||
renderTexture.Create();
|
||||
|
||||
previousSkybox = RenderSettings.skybox;
|
||||
skyboxMaterial = new Material(ResolveSkyboxShader())
|
||||
{
|
||||
name = "Game360VideoMaterial",
|
||||
};
|
||||
skyboxMaterial.SetTexture("_MainTex", renderTexture);
|
||||
skyboxMaterial.SetFloat("_ImageType", 0f);
|
||||
skyboxMaterial.SetFloat("_Mapping", 0f);
|
||||
skyboxMaterial.SetFloat("_Layout", 0f);
|
||||
ApplySkyboxSettings();
|
||||
|
||||
RenderSettings.skybox = skyboxMaterial;
|
||||
DynamicGI.UpdateEnvironment();
|
||||
}
|
||||
|
||||
private void CreateVideoPlayer()
|
||||
{
|
||||
videoPlayerObject = new GameObject("[360 Video Skybox Player]");
|
||||
videoPlayerObject.transform.SetParent(transform, false);
|
||||
|
||||
videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
|
||||
videoPlayer.playOnAwake = false;
|
||||
videoPlayer.isLooping = true;
|
||||
videoPlayer.waitForFirstFrame = true;
|
||||
videoPlayer.renderMode = VideoRenderMode.RenderTexture;
|
||||
videoPlayer.targetTexture = renderTexture;
|
||||
videoPlayer.clip = videoClip;
|
||||
videoPlayer.audioOutputMode = muteVideoAudio
|
||||
? VideoAudioOutputMode.None
|
||||
: VideoAudioOutputMode.Direct;
|
||||
videoPlayer.prepareCompleted += OnVideoPrepared;
|
||||
videoPlayer.errorReceived += OnVideoError;
|
||||
videoPlayer.Prepare();
|
||||
}
|
||||
|
||||
private static Shader ResolveSkyboxShader()
|
||||
{
|
||||
return Shader.Find("Skybox/Panoramic")
|
||||
?? Shader.Find("Skybox/6 Sided")
|
||||
?? Shader.Find("Standard");
|
||||
}
|
||||
|
||||
private void OnVideoPrepared(VideoPlayer source)
|
||||
{
|
||||
source.Play();
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
ApplySkyboxSettings();
|
||||
}
|
||||
|
||||
private void ApplySkyboxSettings()
|
||||
{
|
||||
if (skyboxMaterial == null)
|
||||
return;
|
||||
|
||||
skyboxMaterial.SetFloat("_Exposure", skyboxExposure);
|
||||
skyboxMaterial.SetFloat("_Rotation", skyboxRotationDegrees);
|
||||
DynamicGI.UpdateEnvironment();
|
||||
}
|
||||
|
||||
private static void OnVideoError(VideoPlayer source, string message)
|
||||
{
|
||||
Debug.LogWarning($"[Game360VideoBackground] VideoPlayer error: {message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e381cd99de84f67b9f83c19a032dc24
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
public static class GlobalSyncSettings
|
||||
{
|
||||
private const string AudioOffsetMsKey = "VRBeats.GlobalAudioOffsetMs";
|
||||
|
||||
public static float AudioOffsetMs
|
||||
{
|
||||
get => PlayerPrefs.GetFloat(AudioOffsetMsKey, 0.0f);
|
||||
set
|
||||
{
|
||||
PlayerPrefs.SetFloat(AudioOffsetMsKey, Mathf.Clamp(value, -300.0f, 300.0f));
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public static float AudioOffsetSeconds => AudioOffsetMs / 1000.0f;
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
PlayerPrefs.DeleteKey(AudioOffsetMsKey);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2e8c518ec2f4a03a6d820774b475ce0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class MenuSyncButtonInjector : MonoBehaviour
|
||||
{
|
||||
private const string MenuSceneName = "Menu";
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoCreate()
|
||||
{
|
||||
if (FindFirstObjectByType<MenuSyncButtonInjector>() != null)
|
||||
return;
|
||||
|
||||
GameObject go = new GameObject("[MenuSyncButtonInjector]");
|
||||
DontDestroyOnLoad(go);
|
||||
go.AddComponent<MenuSyncButtonInjector>();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
StartCoroutine(InjectAfterFrame());
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
StartCoroutine(InjectAfterFrame());
|
||||
}
|
||||
|
||||
private IEnumerator InjectAfterFrame()
|
||||
{
|
||||
yield return null;
|
||||
|
||||
if (SceneManager.GetActiveScene().name != MenuSceneName)
|
||||
yield break;
|
||||
|
||||
if (GameObject.Find("SyncButton") != null)
|
||||
yield break;
|
||||
|
||||
Button sourceButton = FindButtonByText("CreateSong");
|
||||
if (sourceButton == null)
|
||||
sourceButton = FindButtonByText("음악만들기");
|
||||
if (sourceButton == null)
|
||||
yield break;
|
||||
|
||||
Button syncButton = Instantiate(sourceButton, sourceButton.transform.parent);
|
||||
syncButton.gameObject.name = "SyncButton";
|
||||
foreach (VRBeats.LoadSceneButton loader in syncButton.GetComponents<VRBeats.LoadSceneButton>())
|
||||
{
|
||||
loader.enabled = false;
|
||||
Destroy(loader);
|
||||
}
|
||||
|
||||
syncButton.onClick.RemoveAllListeners();
|
||||
syncButton.onClick.AddListener(SyncCalibrationOverlay.Open);
|
||||
|
||||
RectTransform sourceRect = sourceButton.GetComponent<RectTransform>();
|
||||
RectTransform syncRect = syncButton.GetComponent<RectTransform>();
|
||||
syncRect.anchoredPosition = sourceRect.anchoredPosition + new Vector2(0.0f, -22.0f);
|
||||
|
||||
TextMeshProUGUI tmp = syncButton.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null)
|
||||
tmp.text = "SYNC";
|
||||
|
||||
Text text = syncButton.GetComponentInChildren<Text>(true);
|
||||
if (text != null)
|
||||
text.text = "SYNC";
|
||||
}
|
||||
|
||||
private static Button FindButtonByText(string text)
|
||||
{
|
||||
foreach (Button button in FindObjectsByType<Button>(FindObjectsSortMode.None))
|
||||
{
|
||||
TextMeshProUGUI tmp = button.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null && tmp.text.Trim() == text)
|
||||
return button;
|
||||
|
||||
Text legacyText = button.GetComponentInChildren<Text>(true);
|
||||
if (legacyText != null && legacyText.text.Trim() == text)
|
||||
return button;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: efc5a20a7b4749bfb60d95ac0f0b2180
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,6 +3,7 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
|
||||
private void LoadConfig()
|
||||
{
|
||||
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
||||
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
NormalizeSettings();
|
||||
return;
|
||||
}
|
||||
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
||||
if (cfg == null) return;
|
||||
_password = cfg.password ?? "";
|
||||
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
|
||||
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
|
||||
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
|
||||
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
|
||||
|
||||
NormalizeSettings();
|
||||
}
|
||||
|
||||
[Serializable] private class NasConfig
|
||||
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
|
||||
Action onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
bool failed = false;
|
||||
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
||||
|
||||
@@ -92,31 +101,53 @@ public class NasPublisher : MonoBehaviour
|
||||
|
||||
private IEnumerator Login(Action<string> onError)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_password))
|
||||
{
|
||||
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=6&method=login" +
|
||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
||||
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
UnityWebRequest req;
|
||||
try
|
||||
{
|
||||
onError?.Invoke($"DSM login failed: {req.error}");
|
||||
req = UnityWebRequest.Get(url);
|
||||
}
|
||||
catch (UriFormatException e)
|
||||
{
|
||||
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string resp = req.downloadHandler.text;
|
||||
_sid = ParseJsonString(resp, "sid");
|
||||
_synoToken = ParseJsonString(resp, "synotoken");
|
||||
string resp;
|
||||
using (req)
|
||||
{
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"DSM login failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
resp = req.downloadHandler.text;
|
||||
_sid = ParseJsonString(resp, "sid");
|
||||
_synoToken = ParseJsonString(resp, "synotoken");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_sid))
|
||||
onError?.Invoke("DSM sid parse failed — check credentials.");
|
||||
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
|
||||
}
|
||||
|
||||
private IEnumerator Logout()
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
|
||||
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
||||
string nasFolder, Action<string> onError)
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
||||
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
||||
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
||||
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
|
||||
|
||||
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
SongsList list = null;
|
||||
|
||||
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
|
||||
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
|
||||
|
||||
private static string ParseJsonString(string json, string key)
|
||||
{
|
||||
string search = $"\"{key}\":\"";
|
||||
int start = json.IndexOf(search, StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
start += search.Length;
|
||||
int end = json.IndexOf('"', start);
|
||||
return end > start ? json.Substring(start, end - start) : null;
|
||||
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
|
||||
return null;
|
||||
|
||||
Match match = Regex.Match(
|
||||
json,
|
||||
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
|
||||
|
||||
return match.Success
|
||||
? Regex.Unescape(match.Groups["value"].Value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void AssignMapFile(SongInfo song, string diff, string fileName)
|
||||
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
|
||||
var info = song.difficulties.Get(diff);
|
||||
if (info != null) info.mapFile = $"maps/{fileName}";
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
NormalizeSettings();
|
||||
}
|
||||
|
||||
private void NormalizeSettings()
|
||||
{
|
||||
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
|
||||
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
|
||||
nasAccount = nasAccount?.Trim() ?? "";
|
||||
nasRootPath = NormalizeRootPath(nasRootPath);
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string value)
|
||||
{
|
||||
return (value ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string NormalizeRootPath(string value)
|
||||
{
|
||||
value = (value ?? "").Trim().Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "/";
|
||||
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string Shorten(string value, int maxLength = 240)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
return value ?? "";
|
||||
|
||||
return value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
|
||||
private const float VerticalCenter = 1f;
|
||||
|
||||
private AudioManager _audio;
|
||||
private ScoreManager _scoreManager;
|
||||
private float _clipLength;
|
||||
|
||||
private static string CacheRoot =>
|
||||
Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
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;
|
||||
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
|
||||
}
|
||||
clip = DownloadHandlerAudioClip.GetContent(req);
|
||||
}
|
||||
_clipLength = clip.length;
|
||||
|
||||
// Load and parse map
|
||||
DifficultyInfo diffInfo = song.difficulties.Get(diff);
|
||||
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
|
||||
yield break;
|
||||
}
|
||||
map.target.Sort(CompareNotes);
|
||||
_scoreManager?.SetTotalNotes(map.target.Count);
|
||||
|
||||
yield return StartCoroutine(Countdown());
|
||||
|
||||
_audio.PlayClip(clip);
|
||||
|
||||
StartCoroutine(SpawnRoutine(map.target));
|
||||
yield return StartCoroutine(WaitForCompletion(clip.length));
|
||||
yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
|
||||
}
|
||||
|
||||
private IEnumerator Countdown()
|
||||
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
|
||||
|
||||
foreach (NoteData note in notes)
|
||||
{
|
||||
float spawnAt = Mathf.Max(0f, note.time - travelTime);
|
||||
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
|
||||
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
|
||||
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
|
||||
SpawnNote(note);
|
||||
}
|
||||
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
|
||||
float y = MapLayerY(note.lineLayer);
|
||||
|
||||
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
|
||||
float remaining = note.time - _audio.CurrentTime;
|
||||
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
|
||||
float travelTime = Mathf.Max(0.05f, remaining);
|
||||
|
||||
var info = new SpawnEventInfo
|
||||
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
|
||||
position = new Vector3(x, y, 0f),
|
||||
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
|
||||
hitDirection = MapCutDirection(note.cutDirection),
|
||||
useSpark = true,
|
||||
useSpark = false,
|
||||
speed = 2f,
|
||||
travelTimeOverride = travelTime,
|
||||
};
|
||||
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
|
||||
private static Direction MapCutDirection(int cut)
|
||||
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
|
||||
|
||||
private IEnumerator WaitForCompletion(float clipLength)
|
||||
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
|
||||
{
|
||||
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.XR;
|
||||
using XRCommonUsages = UnityEngine.XR.CommonUsages;
|
||||
using XRInputDevice = UnityEngine.XR.InputDevice;
|
||||
|
||||
public class SyncCalibrationOverlay : MonoBehaviour
|
||||
{
|
||||
private const float TickInterval = 1.0f;
|
||||
private const int MaxSamples = 8;
|
||||
|
||||
private static Scene pendingReturnScene;
|
||||
|
||||
private readonly List<float> samples = new();
|
||||
private readonly List<(Canvas canvas, bool enabled)> hiddenCanvases = new();
|
||||
|
||||
private AudioSource audioSource = null;
|
||||
private AudioClip tickClip = null;
|
||||
private RectTransform sweepDot = null;
|
||||
private RectTransform visualPulse = null;
|
||||
private TextMeshProUGUI offsetText = null;
|
||||
private TextMeshProUGUI sampleText = null;
|
||||
private TextMeshProUGUI guideText = null;
|
||||
|
||||
private float lastTickTime = 0.0f;
|
||||
private float nextTickTime = 0.0f;
|
||||
private float pendingVisualPulseTime = -1.0f;
|
||||
private float visualPulseTimer = 0.0f;
|
||||
private bool previousPrimary = false;
|
||||
private bool previousSecondary = false;
|
||||
private Scene returnScene;
|
||||
private Scene syncScene;
|
||||
private bool canvasesRestored = false;
|
||||
|
||||
public static void Open()
|
||||
{
|
||||
if (FindFirstObjectByType<SyncCalibrationOverlay>() != null)
|
||||
return;
|
||||
|
||||
pendingReturnScene = SceneManager.GetActiveScene();
|
||||
Scene calibrationScene = SceneManager.CreateScene("SyncCalibration");
|
||||
SceneManager.SetActiveScene(calibrationScene);
|
||||
|
||||
GameObject overlay = new GameObject("[SyncCalibrationOverlay]");
|
||||
overlay.AddComponent<SyncCalibrationOverlay>();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
syncScene = gameObject.scene;
|
||||
returnScene = pendingReturnScene;
|
||||
|
||||
HideExistingCanvases();
|
||||
BuildView();
|
||||
tickClip = CreateTickClip();
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.spatialBlend = 0.0f;
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
lastTickTime = now;
|
||||
nextTickTime = now + 0.8f;
|
||||
UpdateTexts();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
RestoreCanvases();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
if (now >= nextTickTime)
|
||||
{
|
||||
lastTickTime = nextTickTime;
|
||||
nextTickTime += TickInterval;
|
||||
audioSource.PlayOneShot(tickClip);
|
||||
pendingVisualPulseTime = lastTickTime + GlobalSyncSettings.AudioOffsetSeconds;
|
||||
}
|
||||
|
||||
if (pendingVisualPulseTime > 0.0f && now >= pendingVisualPulseTime)
|
||||
{
|
||||
visualPulseTimer = 0.18f;
|
||||
pendingVisualPulseTime = -1.0f;
|
||||
}
|
||||
|
||||
UpdateMetronomeVisual(now);
|
||||
HandleInput(now);
|
||||
}
|
||||
|
||||
private void HandleInput(float now)
|
||||
{
|
||||
bool primary = GetRightButton(XRCommonUsages.primaryButton) || IsKeyboardPressed(Key.Space);
|
||||
bool secondary = GetRightButton(XRCommonUsages.secondaryButton) || IsKeyboardPressed(Key.Escape);
|
||||
|
||||
if (primary && !previousPrimary)
|
||||
CaptureSample(now);
|
||||
if (secondary && !previousSecondary)
|
||||
Close();
|
||||
|
||||
previousPrimary = primary;
|
||||
previousSecondary = secondary;
|
||||
}
|
||||
|
||||
private void CaptureSample(float now)
|
||||
{
|
||||
float nearestTick = Mathf.Abs(now - lastTickTime) <= Mathf.Abs(now - nextTickTime)
|
||||
? lastTickTime
|
||||
: nextTickTime;
|
||||
|
||||
float offsetMs = Mathf.Clamp((now - nearestTick) * 1000.0f, -300.0f, 300.0f);
|
||||
samples.Add(offsetMs);
|
||||
if (samples.Count > MaxSamples)
|
||||
samples.RemoveAt(0);
|
||||
|
||||
float sum = 0.0f;
|
||||
for (int i = 0; i < samples.Count; i++)
|
||||
sum += samples[i];
|
||||
|
||||
GlobalSyncSettings.AudioOffsetMs = sum / samples.Count;
|
||||
UpdateTexts();
|
||||
}
|
||||
|
||||
private void AdjustOffset(float deltaMs)
|
||||
{
|
||||
GlobalSyncSettings.AudioOffsetMs += deltaMs;
|
||||
samples.Clear();
|
||||
UpdateTexts();
|
||||
}
|
||||
|
||||
private void ResetOffset()
|
||||
{
|
||||
GlobalSyncSettings.Reset();
|
||||
samples.Clear();
|
||||
UpdateTexts();
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
RestoreCanvases();
|
||||
|
||||
if (returnScene.IsValid() && returnScene.isLoaded)
|
||||
SceneManager.SetActiveScene(returnScene);
|
||||
|
||||
if (syncScene.IsValid() && syncScene.isLoaded)
|
||||
SceneManager.UnloadSceneAsync(syncScene);
|
||||
else
|
||||
Destroy(gameObject);
|
||||
}
|
||||
|
||||
private void BuildView()
|
||||
{
|
||||
Camera camera = Camera.main ?? FindFirstObjectByType<Camera>();
|
||||
|
||||
GameObject canvasObject = new GameObject("SyncCalibrationCanvas");
|
||||
canvasObject.transform.SetParent(transform, false);
|
||||
Canvas canvas = canvasObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.WorldSpace;
|
||||
canvas.sortingOrder = 400;
|
||||
canvas.worldCamera = camera;
|
||||
canvasObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
RectTransform canvasRect = canvasObject.GetComponent<RectTransform>();
|
||||
canvasRect.sizeDelta = new Vector2(1080.0f, 660.0f);
|
||||
canvasObject.transform.localScale = Vector3.one * 0.0028f;
|
||||
|
||||
if (camera != null)
|
||||
{
|
||||
Transform camTransform = camera.transform;
|
||||
canvasObject.transform.position = camTransform.position + camTransform.forward * 1.9f;
|
||||
canvasObject.transform.rotation = Quaternion.LookRotation(canvasObject.transform.position - camTransform.position, camTransform.up);
|
||||
}
|
||||
|
||||
Image background = CreateImage("Panel", canvasRect, new Vector2(0, 0), new Vector2(1020, 620), new Color(0.02f, 0.06f, 0.09f, 0.92f));
|
||||
background.raycastTarget = false;
|
||||
|
||||
TextMeshProUGUI title = CreateText("Title", canvasRect, "SYNC CALIBRATION", new Vector2(0, 260), new Vector2(920, 58), 42, Color.white, TextAlignmentOptions.Center);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
guideText = CreateText("Guide", canvasRect, "", new Vector2(0, 180), new Vector2(920, 92), 26, new Color(0.77f, 0.9f, 1.0f, 1.0f), TextAlignmentOptions.Center);
|
||||
|
||||
Image bar = CreateImage("BeatBar", canvasRect, new Vector2(0, 82), new Vector2(760, 10), new Color(0.25f, 0.85f, 1.0f, 0.28f));
|
||||
bar.raycastTarget = false;
|
||||
sweepDot = CreateImage("BeatDot", canvasRect, new Vector2(-380, 82), new Vector2(34, 34), new Color(0.35f, 0.95f, 1.0f, 1.0f)).rectTransform;
|
||||
visualPulse = CreateImage("VisualPulse", canvasRect, new Vector2(0, 82), new Vector2(140, 140), new Color(0.35f, 0.95f, 1.0f, 0.0f)).rectTransform;
|
||||
|
||||
offsetText = CreateText("Offset", canvasRect, "", new Vector2(0, 0), new Vector2(720, 74), 54, Color.white, TextAlignmentOptions.Center);
|
||||
offsetText.fontStyle = FontStyles.Bold;
|
||||
sampleText = CreateText("Samples", canvasRect, "", new Vector2(0, -72), new Vector2(760, 44), 24, new Color(0.65f, 0.78f, 0.84f, 1.0f), TextAlignmentOptions.Center);
|
||||
|
||||
CreateButton(canvasRect, "-10ms", new Vector2(-300, -165), () => AdjustOffset(-10.0f));
|
||||
CreateButton(canvasRect, "+10ms", new Vector2(-100, -165), () => AdjustOffset(10.0f));
|
||||
CreateButton(canvasRect, "RESET", new Vector2(100, -165), ResetOffset);
|
||||
CreateButton(canvasRect, "BACK", new Vector2(300, -165), Close);
|
||||
|
||||
CreateText("Footer", canvasRect, "A / Space: capture beat B / Esc: back", new Vector2(0, -270), new Vector2(860, 40), 22, new Color(0.58f, 0.7f, 0.75f, 1.0f), TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
private void UpdateMetronomeVisual(float now)
|
||||
{
|
||||
float phase = Mathf.InverseLerp(lastTickTime, nextTickTime, now);
|
||||
if (sweepDot != null)
|
||||
sweepDot.anchoredPosition = new Vector2(Mathf.Lerp(-380.0f, 380.0f, phase), 82.0f);
|
||||
|
||||
if (visualPulse == null)
|
||||
return;
|
||||
|
||||
visualPulseTimer = Mathf.Max(0.0f, visualPulseTimer - Time.unscaledDeltaTime);
|
||||
float alpha = visualPulseTimer / 0.18f;
|
||||
visualPulse.sizeDelta = Vector2.one * Mathf.Lerp(190.0f, 80.0f, alpha);
|
||||
Image image = visualPulse.GetComponent<Image>();
|
||||
if (image != null)
|
||||
image.color = new Color(0.35f, 0.95f, 1.0f, alpha * 0.52f);
|
||||
}
|
||||
|
||||
private void UpdateTexts()
|
||||
{
|
||||
float offset = GlobalSyncSettings.AudioOffsetMs;
|
||||
if (offsetText != null)
|
||||
offsetText.text = $"{offset:+0;-0;0} ms";
|
||||
if (sampleText != null)
|
||||
sampleText.text = $"samples {samples.Count}/{MaxSamples} global offset saved";
|
||||
if (guideText != null)
|
||||
guideText.text = "Tick 소리가 들리는 순간 A / Space를 누르세요.\n파란 원이 박자와 겹치면 보정이 맞습니다.";
|
||||
}
|
||||
|
||||
private void HideExistingCanvases()
|
||||
{
|
||||
hiddenCanvases.Clear();
|
||||
foreach (Canvas canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
||||
{
|
||||
hiddenCanvases.Add((canvas, canvas.enabled));
|
||||
canvas.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreCanvases()
|
||||
{
|
||||
if (canvasesRestored)
|
||||
return;
|
||||
|
||||
canvasesRestored = true;
|
||||
foreach ((Canvas canvas, bool enabled) in hiddenCanvases)
|
||||
{
|
||||
if (canvas != null)
|
||||
canvas.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
private static TextMeshProUGUI CreateText(string name, RectTransform parent, string value, Vector2 position, Vector2 size, int fontSize, Color color, TextAlignmentOptions alignment)
|
||||
{
|
||||
GameObject go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
RectTransform rect = go.AddComponent<RectTransform>();
|
||||
rect.anchoredPosition = position;
|
||||
rect.sizeDelta = size;
|
||||
|
||||
TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
|
||||
text.text = value;
|
||||
text.fontSize = fontSize;
|
||||
text.color = color;
|
||||
text.alignment = alignment;
|
||||
text.textWrappingMode = TextWrappingModes.Normal;
|
||||
text.overflowMode = TextOverflowModes.Overflow;
|
||||
text.raycastTarget = false;
|
||||
return text;
|
||||
}
|
||||
|
||||
private static Image CreateImage(string name, RectTransform parent, Vector2 position, Vector2 size, Color color)
|
||||
{
|
||||
GameObject go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
RectTransform rect = go.AddComponent<RectTransform>();
|
||||
rect.anchoredPosition = position;
|
||||
rect.sizeDelta = size;
|
||||
|
||||
Image image = go.AddComponent<Image>();
|
||||
image.color = color;
|
||||
return image;
|
||||
}
|
||||
|
||||
private static void CreateButton(RectTransform parent, string label, Vector2 position, UnityEngine.Events.UnityAction action)
|
||||
{
|
||||
Image image = CreateImage(label + "Button", parent, position, new Vector2(168, 58), new Color(0.07f, 0.18f, 0.24f, 0.96f));
|
||||
Button button = image.gameObject.AddComponent<Button>();
|
||||
button.onClick.AddListener(action);
|
||||
|
||||
ColorBlock colors = button.colors;
|
||||
colors.normalColor = new Color(0.07f, 0.18f, 0.24f, 0.96f);
|
||||
colors.highlightedColor = new Color(0.13f, 0.38f, 0.48f, 1.0f);
|
||||
colors.pressedColor = new Color(0.08f, 0.72f, 0.85f, 1.0f);
|
||||
colors.selectedColor = colors.highlightedColor;
|
||||
button.colors = colors;
|
||||
|
||||
TextMeshProUGUI labelText = CreateText(label + "Text", image.rectTransform, label, Vector2.zero, new Vector2(154, 48), 24, Color.white, TextAlignmentOptions.Center);
|
||||
labelText.fontStyle = FontStyles.Bold;
|
||||
}
|
||||
|
||||
private static bool GetRightButton(InputFeatureUsage<bool> usage)
|
||||
{
|
||||
var devices = new List<XRInputDevice>();
|
||||
InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller | InputDeviceCharacteristics.Right, devices);
|
||||
if (devices.Count == 0)
|
||||
return false;
|
||||
|
||||
devices[0].TryGetFeatureValue(usage, out bool pressed);
|
||||
return pressed;
|
||||
}
|
||||
|
||||
private static bool IsKeyboardPressed(Key key)
|
||||
{
|
||||
Keyboard keyboard = Keyboard.current;
|
||||
return keyboard != null && keyboard[key].isPressed;
|
||||
}
|
||||
|
||||
private static AudioClip CreateTickClip()
|
||||
{
|
||||
const int sampleRate = 48000;
|
||||
const float duration = 0.055f;
|
||||
int sampleCount = Mathf.CeilToInt(sampleRate * duration);
|
||||
float[] data = new float[sampleCount];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
float t = (float)i / sampleRate;
|
||||
float envelope = Mathf.Exp(-t * 62.0f);
|
||||
float high = Mathf.Sin(2.0f * Mathf.PI * 1760.0f * t);
|
||||
float click = i < 80 ? 1.0f - (float)i / 80.0f : 0.0f;
|
||||
data[i] = Mathf.Clamp((high * 0.75f + click * 0.35f) * envelope, -1.0f, 1.0f);
|
||||
}
|
||||
|
||||
AudioClip clip = AudioClip.Create("SyncTick", sampleCount, 1, sampleRate, false);
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90f8d18d467240d8bb84178048a5aa91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,10 +11,20 @@ namespace VRBeats
|
||||
{
|
||||
[SerializeField] private bool isRightHand = true;
|
||||
[SerializeField] private float maxDistance = 50f;
|
||||
[SerializeField] private bool debugLogging = false;
|
||||
[SerializeField] private float scrollSpeed = 2.4f;
|
||||
[SerializeField] private float scrollDeadZone = 0.15f;
|
||||
[SerializeField] private float dragScrollSpeed = 1.25f;
|
||||
[SerializeField] private float dragClickThreshold = 0.025f;
|
||||
|
||||
private LineRenderer _line;
|
||||
private bool _prevTrigger;
|
||||
private Selectable _currentHover;
|
||||
private ScrollRect _dragScrollRect;
|
||||
private Selectable _triggerPressSelectable;
|
||||
private Vector2 _dragStartLocalPoint;
|
||||
private float _dragStartNormalizedPosition;
|
||||
private float _dragMaxNormalizedDelta;
|
||||
|
||||
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
|
||||
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
|
||||
@@ -40,7 +50,8 @@ namespace VRBeats
|
||||
private void Start()
|
||||
{
|
||||
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
|
||||
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
|
||||
if (debugLogging)
|
||||
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
@@ -56,11 +67,14 @@ namespace VRBeats
|
||||
private void Update()
|
||||
{
|
||||
// 3초마다 연결된 디바이스 목록 출력
|
||||
_deviceLogTimer += Time.deltaTime;
|
||||
if (_deviceLogTimer >= 3f)
|
||||
if (debugLogging)
|
||||
{
|
||||
_deviceLogTimer = 0f;
|
||||
LogConnectedDevices();
|
||||
_deviceLogTimer += Time.deltaTime;
|
||||
if (_deviceLogTimer >= 3f)
|
||||
{
|
||||
_deviceLogTimer = 0f;
|
||||
LogConnectedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
bool trigger = GetButton(CommonUsages.triggerButton);
|
||||
@@ -70,31 +84,41 @@ namespace VRBeats
|
||||
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
|
||||
|
||||
bool triggerDown = trigger && !_prevTrigger;
|
||||
bool triggerUp = !trigger && _prevTrigger;
|
||||
bool gripDown = grip && !_prevGrip;
|
||||
bool primaryDown = primary && !_prevPrimary;
|
||||
bool secondaryDown = secondary && !_prevSecondary;
|
||||
bool thumbstickDown = thumbstick && !_prevThumbstick;
|
||||
|
||||
_prevTrigger = trigger;
|
||||
_prevGrip = grip;
|
||||
_prevPrimary = primary;
|
||||
_prevSecondary = secondary;
|
||||
_prevThumbstick = thumbstick;
|
||||
|
||||
string hand = isRightHand ? "R" : "L";
|
||||
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
|
||||
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
|
||||
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
|
||||
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
|
||||
if (thumbstickDown)Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
|
||||
if (debugLogging)
|
||||
{
|
||||
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
|
||||
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
|
||||
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
|
||||
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
|
||||
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
|
||||
}
|
||||
|
||||
Ray ray = new Ray(transform.position, transform.forward);
|
||||
float hitDist = maxDistance;
|
||||
float selectableHitDist = maxDistance;
|
||||
float scrollHitDist = maxDistance;
|
||||
|
||||
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
|
||||
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
|
||||
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
|
||||
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
|
||||
|
||||
bool beganScrollDrag = false;
|
||||
if (triggerDown)
|
||||
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
|
||||
|
||||
if (_dragScrollRect != null && trigger)
|
||||
UpdateScrollDrag(ray);
|
||||
else if (_dragScrollRect == null)
|
||||
HandleScroll(scrollRect);
|
||||
|
||||
// 호버 변화 로그
|
||||
if (hit != _currentHover)
|
||||
if (debugLogging && hit != _currentHover)
|
||||
{
|
||||
Debug.Log(hit != null
|
||||
? $"[VRPointer] HOVER → {hit.gameObject.name}"
|
||||
@@ -103,16 +127,21 @@ namespace VRBeats
|
||||
|
||||
UpdateHoverState(hit);
|
||||
|
||||
// 검지 트리거 또는 A/X 버튼으로 클릭
|
||||
if (triggerDown || primaryDown)
|
||||
if (triggerUp && _dragScrollRect != null)
|
||||
EndScrollDrag(hand, ray);
|
||||
|
||||
// 검지 트리거 또는 A/X 버튼으로 클릭.
|
||||
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
|
||||
if ((triggerDown && !beganScrollDrag) || primaryDown)
|
||||
{
|
||||
if (_currentHover != null)
|
||||
{
|
||||
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
|
||||
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
|
||||
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
|
||||
if (debugLogging)
|
||||
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
|
||||
Click(_currentHover);
|
||||
}
|
||||
else
|
||||
else if (debugLogging)
|
||||
{
|
||||
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
|
||||
$"pos={transform.position:F2} fwd={transform.forward:F2}");
|
||||
@@ -121,6 +150,12 @@ namespace VRBeats
|
||||
}
|
||||
|
||||
DrawLine(hitDist);
|
||||
|
||||
_prevTrigger = trigger;
|
||||
_prevGrip = grip;
|
||||
_prevPrimary = primary;
|
||||
_prevSecondary = secondary;
|
||||
_prevThumbstick = thumbstick;
|
||||
}
|
||||
|
||||
private void LogConnectedDevices()
|
||||
@@ -222,6 +257,7 @@ namespace VRBeats
|
||||
foreach (Selectable sel in Selectable.allSelectablesArray)
|
||||
{
|
||||
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
|
||||
if (!IsOnEnabledCanvas(sel)) continue;
|
||||
|
||||
var rt = sel.GetComponent<RectTransform>();
|
||||
if (rt == null) continue;
|
||||
@@ -260,6 +296,189 @@ namespace VRBeats
|
||||
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
|
||||
}
|
||||
|
||||
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
|
||||
{
|
||||
ScrollRect closest = null;
|
||||
float closestDist = maxDist;
|
||||
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
|
||||
|
||||
foreach (ScrollRect scroll in all)
|
||||
{
|
||||
if (!scroll.isActiveAndEnabled) continue;
|
||||
if (!IsOnEnabledCanvas(scroll)) continue;
|
||||
|
||||
RectTransform rt = scroll.viewport != null
|
||||
? scroll.viewport
|
||||
: scroll.GetComponent<RectTransform>();
|
||||
if (rt == null) continue;
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
rt.GetWorldCorners(corners);
|
||||
|
||||
Vector3 normal = rt.forward;
|
||||
if (Vector3.Dot(normal, ray.direction) >= 0f)
|
||||
normal = -normal;
|
||||
|
||||
Plane plane = new Plane(normal, corners[0]);
|
||||
if (!plane.Raycast(ray, out float dist)) continue;
|
||||
if (dist >= closestDist || dist <= 0f) continue;
|
||||
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
|
||||
|
||||
closestDist = dist;
|
||||
closest = scroll;
|
||||
}
|
||||
|
||||
if (closest != null)
|
||||
maxDist = closestDist;
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
private static bool IsOnEnabledCanvas(Component component)
|
||||
{
|
||||
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
|
||||
if (canvases.Length == 0)
|
||||
return true;
|
||||
|
||||
for (int i = 0; i < canvases.Length; i++)
|
||||
{
|
||||
Canvas canvas = canvases[i];
|
||||
if (canvas == null)
|
||||
continue;
|
||||
|
||||
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void HandleScroll(ScrollRect scrollRect)
|
||||
{
|
||||
if (!CanScrollVertically(scrollRect))
|
||||
return;
|
||||
|
||||
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
|
||||
if (Mathf.Abs(axis.y) < scrollDeadZone)
|
||||
return;
|
||||
|
||||
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
|
||||
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
|
||||
}
|
||||
|
||||
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
|
||||
{
|
||||
if (!CanScrollVertically(scrollRect))
|
||||
return false;
|
||||
|
||||
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
|
||||
return false;
|
||||
|
||||
_dragScrollRect = scrollRect;
|
||||
_triggerPressSelectable = pressSelectable;
|
||||
_dragStartLocalPoint = localPoint;
|
||||
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
|
||||
_dragMaxNormalizedDelta = 0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateScrollDrag(Ray ray)
|
||||
{
|
||||
if (_dragScrollRect == null)
|
||||
return;
|
||||
|
||||
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
|
||||
return;
|
||||
|
||||
float deltaY = localPoint.y - _dragStartLocalPoint.y;
|
||||
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
|
||||
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
|
||||
|
||||
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
|
||||
_dragStartNormalizedPosition - normalizedDelta);
|
||||
}
|
||||
|
||||
private void EndScrollDrag(string hand, Ray ray)
|
||||
{
|
||||
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
|
||||
ScrollRect scrollRect = _dragScrollRect;
|
||||
Selectable pressSelectable = _triggerPressSelectable;
|
||||
float startNormalizedPosition = _dragStartNormalizedPosition;
|
||||
|
||||
ClearScrollDrag();
|
||||
|
||||
if (!shouldClick)
|
||||
return;
|
||||
|
||||
if (scrollRect != null)
|
||||
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
|
||||
|
||||
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
|
||||
{
|
||||
if (debugLogging)
|
||||
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
|
||||
Click(pressSelectable);
|
||||
}
|
||||
else if (debugLogging)
|
||||
{
|
||||
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
|
||||
$"pos={transform.position:F2} fwd={transform.forward:F2}");
|
||||
DebugRaycastAttempt(ray);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearScrollDrag()
|
||||
{
|
||||
_dragScrollRect = null;
|
||||
_triggerPressSelectable = null;
|
||||
_dragStartLocalPoint = Vector2.zero;
|
||||
_dragStartNormalizedPosition = 0f;
|
||||
_dragMaxNormalizedDelta = 0f;
|
||||
}
|
||||
|
||||
private static bool CanScrollVertically(ScrollRect scrollRect)
|
||||
{
|
||||
if (scrollRect == null || !scrollRect.vertical)
|
||||
return false;
|
||||
|
||||
RectTransform viewport = scrollRect.viewport != null
|
||||
? scrollRect.viewport
|
||||
: scrollRect.GetComponent<RectTransform>();
|
||||
|
||||
if (viewport == null || scrollRect.content == null)
|
||||
return true;
|
||||
|
||||
return scrollRect.content.rect.height > viewport.rect.height + 1f;
|
||||
}
|
||||
|
||||
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
|
||||
{
|
||||
localPoint = Vector2.zero;
|
||||
viewportHeight = 1f;
|
||||
|
||||
RectTransform rt = scrollRect.viewport != null
|
||||
? scrollRect.viewport
|
||||
: scrollRect.GetComponent<RectTransform>();
|
||||
if (rt == null)
|
||||
return false;
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
rt.GetWorldCorners(corners);
|
||||
|
||||
Vector3 normal = rt.forward;
|
||||
if (Vector3.Dot(normal, ray.direction) >= 0f)
|
||||
normal = -normal;
|
||||
|
||||
Plane plane = new Plane(normal, corners[0]);
|
||||
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
|
||||
return false;
|
||||
|
||||
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
|
||||
localPoint = new Vector2(local.x, local.y);
|
||||
viewportHeight = Mathf.Max(1f, rt.rect.height);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool GetButton(InputFeatureUsage<bool> usage)
|
||||
{
|
||||
var chars = InputDeviceCharacteristics.Controller |
|
||||
@@ -274,5 +493,20 @@ namespace VRBeats
|
||||
devices[0].TryGetFeatureValue(usage, out bool pressed);
|
||||
return pressed;
|
||||
}
|
||||
|
||||
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
|
||||
{
|
||||
var chars = InputDeviceCharacteristics.Controller |
|
||||
(isRightHand
|
||||
? InputDeviceCharacteristics.Right
|
||||
: InputDeviceCharacteristics.Left);
|
||||
|
||||
var devices = new List<InputDevice>();
|
||||
InputDevices.GetDevicesWithCharacteristics(chars, devices);
|
||||
if (devices.Count == 0) return Vector2.zero;
|
||||
|
||||
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
|
||||
return axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ namespace VRBeats
|
||||
if (!isRight && !isLeft) continue;
|
||||
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
|
||||
if (go.GetComponent<LineRenderer>() == null) continue;
|
||||
|
||||
DisableToolkitPointerComponents(go);
|
||||
|
||||
if (go.GetComponent<VRPointerController>() != null) continue;
|
||||
|
||||
var pointer = go.AddComponent<VRPointerController>();
|
||||
@@ -94,8 +97,18 @@ namespace VRBeats
|
||||
if (disabledByDefault)
|
||||
pointer.enabled = false;
|
||||
|
||||
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisableToolkitPointerComponents(GameObject go)
|
||||
{
|
||||
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
|
||||
if (rayInteractor != null)
|
||||
rayInteractor.enabled = false;
|
||||
|
||||
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
|
||||
if (lineVisual != null)
|
||||
lineVisual.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user