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 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() != null) return; pendingReturnScene = SceneManager.GetActiveScene(); Scene calibrationScene = SceneManager.CreateScene("SyncCalibration"); SceneManager.SetActiveScene(calibrationScene); GameObject overlay = new GameObject("[SyncCalibrationOverlay]"); overlay.AddComponent(); } private void Awake() { syncScene = gameObject.scene; returnScene = pendingReturnScene; HideExistingCanvases(); BuildView(); tickClip = CreateTickClip(); audioSource = gameObject.AddComponent(); 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(); GameObject canvasObject = new GameObject("SyncCalibrationCanvas"); canvasObject.transform.SetParent(transform, false); Canvas canvas = canvasObject.AddComponent(); canvas.renderMode = RenderMode.WorldSpace; canvas.sortingOrder = 400; canvas.worldCamera = camera; canvasObject.AddComponent(); RectTransform canvasRect = canvasObject.GetComponent(); 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(); 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(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(); rect.anchoredPosition = position; rect.sizeDelta = size; TextMeshProUGUI text = go.AddComponent(); 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(); rect.anchoredPosition = position; rect.sizeDelta = size; Image image = go.AddComponent(); 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