feat: polish VR gameplay and sync tools
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user