diff --git a/Assets/Editor/UnityCodexBridgeServer.cs b/Assets/Editor/UnityCodexBridgeServer.cs index afc4e1e..15c1b8c 100644 --- a/Assets/Editor/UnityCodexBridgeServer.cs +++ b/Assets/Editor/UnityCodexBridgeServer.cs @@ -17,7 +17,8 @@ namespace VRBeats.EditorTools [InitializeOnLoad] internal static class UnityCodexBridgeServer { - private const int Port = 19744; + private const int PreferredPort = 19744; + private const int MaxPortAttempts = 5; private const string AutoStartPrefKey = "VRBeats.CodexBridge.AutoStart"; private const int MaxLogs = 250; @@ -28,6 +29,7 @@ namespace VRBeats.EditorTools private static TcpListener _listener; private static Thread _serverThread; private static bool _running; + private static int _port = PreferredPort; private static int _logIndex; static UnityCodexBridgeServer() @@ -103,9 +105,7 @@ namespace VRBeats.EditorTools try { - _listener = new TcpListener(IPAddress.Loopback, Port); - _listener.Server.ExclusiveAddressUse = true; - _listener.Start(); + _listener = CreateListener(); _running = true; _serverThread = new Thread(ServerLoop) @@ -115,7 +115,7 @@ namespace VRBeats.EditorTools }; _serverThread.Start(); - Debug.Log("[CodexBridge] Listening on http://127.0.0.1:" + Port); + Debug.Log("[CodexBridge] Listening on http://127.0.0.1:" + _port); } catch (Exception ex) { @@ -160,6 +160,42 @@ namespace VRBeats.EditorTools _serverThread = null; } + private static TcpListener CreateListener() + { + Exception lastException = null; + + for (int i = 0; i < MaxPortAttempts; i++) + { + int port = PreferredPort + i; + TcpListener listener = null; + + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Server.ExclusiveAddressUse = true; + listener.Start(); + _port = port; + return listener; + } + catch (Exception ex) + { + lastException = ex; + + try + { + if (listener != null) + listener.Stop(); + } + catch + { + // Ignore cleanup failures while trying fallback ports. + } + } + } + + throw lastException ?? new SocketException(); + } + private static bool IsBackgroundEditorProcess() { string commandLine = Environment.CommandLine; @@ -391,7 +427,7 @@ namespace VRBeats.EditorTools string body = "{\"ok\":true" + ",\"bridge\":\"unity-codex-bridge\"" + - ",\"port\":" + Port.ToString(CultureInfo.InvariantCulture) + + ",\"port\":" + _port.ToString(CultureInfo.InvariantCulture) + ",\"unityVersion\":" + JsonString(Application.unityVersion) + ",\"projectPath\":" + JsonString(Directory.GetCurrentDirectory()) + ",\"scene\":" + JsonString(scene.IsValid() ? scene.name : string.Empty) + diff --git a/Assets/Scenes/Game.unity b/Assets/Scenes/Game.unity index 1fd9900..9391048 100644 --- a/Assets/Scenes/Game.unity +++ b/Assets/Scenes/Game.unity @@ -1257,8 +1257,8 @@ RectTransform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 454873725} m_LocalRotation: {x: -0.007861641, y: 0.97856337, z: 0.038037617, w: 0.20225017} - m_LocalPosition: {x: 0, y: 0, z: 17.8} - m_LocalScale: {x: 0.0049999994, y: 0.005, z: 0.005} + m_LocalPosition: {x: 0, y: 0, z: 5} + m_LocalScale: {x: 0.006, y: 0.006, z: 0.006} m_ConstrainProportionsScale: 0 m_Children: - {fileID: 1800899779} @@ -1268,7 +1268,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} - m_AnchoredPosition: {x: 5.8, y: 2.4} + m_AnchoredPosition: {x: 0, y: 2.4} m_SizeDelta: {x: 847.5, y: 1141.086} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &454873730 @@ -1293,11 +1293,11 @@ MonoBehaviour: accuracyLabel: {fileID: 0} judgementLabel: {fileID: 0} createMissingHudLabels: 1 - applyHudPlacement: 1 - hudAnchoredPosition: {x: 5.8, y: 2.4} - perfectWindow: 0.08 - greatWindow: 0.15 - goodWindow: 0.25 + applyHudPlacement: 0 + hudAnchoredPosition: {x: 0, y: 2.4} + perfectWindow: 0.11 + greatWindow: 0.2 + goodWindow: 0.32 --- !u!114 &454873731 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Script/SongController.cs b/Assets/Script/SongController.cs index 4d1168e..d9275ae 100644 --- a/Assets/Script/SongController.cs +++ b/Assets/Script/SongController.cs @@ -14,9 +14,10 @@ public class SongController : MonoBehaviour [SerializeField] private TMP_Text countdownText; private const float LaneSpacing = 0.42f; - private const float LayerSpacing = 0.38f; + 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; @@ -84,6 +85,11 @@ public class SongController : MonoBehaviour 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()); @@ -91,7 +97,7 @@ public class SongController : MonoBehaviour _audio.PlayClip(clip); StartCoroutine(SpawnRoutine(map.target)); - yield return StartCoroutine(WaitForCompletion(clip.length, map.target)); + yield return StartCoroutine(WaitForCompletion(_clipLength, map.target)); } private IEnumerator Countdown() @@ -168,7 +174,7 @@ public class SongController : MonoBehaviour private static float MapLayerY(int lineLayer) { int layer = Mathf.Clamp(lineLayer, 0, 2); - return (layer - VerticalCenter) * LayerSpacing; + return VerticalOffset + (layer - VerticalCenter) * LayerSpacing; } // Beat Saber cutDirection → VRBeats Direction diff --git a/Assets/VRBeatsKit/Scripts/Core/AudioManager.cs b/Assets/VRBeatsKit/Scripts/Core/AudioManager.cs index aa261fa..518d692 100644 --- a/Assets/VRBeatsKit/Scripts/Core/AudioManager.cs +++ b/Assets/VRBeatsKit/Scripts/Core/AudioManager.cs @@ -69,10 +69,10 @@ namespace VRBeats if (audioSource == null) return 0.0f; - if (hasScheduledClip) - return (float)(AudioSettings.dspTime - scheduledDspStartTime); + if (hasScheduledClip || scheduledDspStartTime >= 0.0) + return Mathf.Max(0.0f, (float)(AudioSettings.dspTime - scheduledDspStartTime)); - return audioSource.time; + return 0.0f; } } diff --git a/Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs b/Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs index b2e66e7..bbedc55 100644 --- a/Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs +++ b/Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs @@ -98,8 +98,9 @@ namespace VRBeats TrimExpiredSamples(now); BuildRibbon(wideMesh, now, 0f, 1f, trailColor, 0.10f, 0.36f); - Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f); - BuildRibbon(coreMesh, now, 0.42f, 1f, coreColor, 0.14f, 0.48f); + Color coreColor = Color.Lerp(Color.white, trailColor, CoreColorWeight(trailColor)); + float alphaMultiplier = VisibilityAlphaMultiplier(trailColor); + BuildRibbon(coreMesh, now, 0.42f, 1f, coreColor, 0.14f * alphaMultiplier, 0.48f * alphaMultiplier); lastBase = basePos; lastTip = tipPos; @@ -147,10 +148,11 @@ namespace VRBeats { trailColor = NormalizeColor(color); EnsureRenderers(); - ApplyMaterialColor(wideMaterial, trailColor, 0.34f); + float alphaMultiplier = VisibilityAlphaMultiplier(trailColor); + ApplyMaterialColor(wideMaterial, trailColor, 0.34f * alphaMultiplier); - Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f); - ApplyMaterialColor(coreMaterial, coreColor, 0.50f); + Color coreColor = Color.Lerp(Color.white, trailColor, CoreColorWeight(trailColor)); + ApplyMaterialColor(coreMaterial, coreColor, 0.50f * alphaMultiplier); } private void ResolveBladeAnchors() @@ -424,6 +426,21 @@ namespace VRBeats return color; } + private static float CoreColorWeight(Color color) + { + return IsBlueDominant(color) ? 0.78f : 0.45f; + } + + private static float VisibilityAlphaMultiplier(Color color) + { + return IsBlueDominant(color) ? 1.35f : 1f; + } + + private static bool IsBlueDominant(Color color) + { + return color.b > color.r && color.b >= color.g; + } + private static Color WithAlpha(Color color, float alpha) { color.a = Mathf.Clamp01(alpha); diff --git a/Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs b/Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs index 51053af..36cd756 100644 --- a/Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs +++ b/Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs @@ -32,8 +32,9 @@ namespace VRBeats tangent = saberUp.sqrMagnitude > 0.001f ? saberUp.normalized : Vector3.right; lift = hitDir.sqrMagnitude > 0.001f ? hitDir.normalized : Vector3.up; - glowLine = CreateLine("Glow", 0.16f, 0.45f); - coreLine = CreateLine("Core", 0.045f, 0.95f); + float widthMultiplier = IsBlueDominant(effectColor) ? 1.18f : 1.0f; + glowLine = CreateLine("Glow", 0.16f * widthMultiplier, 0.45f); + coreLine = CreateLine("Core", 0.045f * widthMultiplier, 0.95f); StartCoroutine(Animate()); } @@ -67,8 +68,9 @@ namespace VRBeats float bend = Mathf.Lerp(0.12f, 0.34f, t); float alpha = 1.0f - t; - UpdateLine(glowLine, length, bend, alpha * 0.45f); - UpdateLine(coreLine, length * 0.88f, bend * 0.55f, alpha * 0.95f); + float alphaMultiplier = VisibilityAlphaMultiplier(effectColor); + UpdateLine(glowLine, length, bend, alpha * 0.45f * alphaMultiplier); + UpdateLine(coreLine, length * 0.88f, bend * 0.55f, alpha * 0.95f * alphaMultiplier); age += Time.deltaTime; yield return null; @@ -136,5 +138,15 @@ namespace VRBeats color.a = 1.0f; return color; } + + private static float VisibilityAlphaMultiplier(Color color) + { + return IsBlueDominant(color) ? 1.35f : 1.0f; + } + + private static bool IsBlueDominant(Color color) + { + return color.b > color.r && color.b >= color.g; + } } } diff --git a/Assets/VRBeatsKit/Scripts/Other/Spark.cs b/Assets/VRBeatsKit/Scripts/Other/Spark.cs index c01246a..8f65c3e 100644 --- a/Assets/VRBeatsKit/Scripts/Other/Spark.cs +++ b/Assets/VRBeatsKit/Scripts/Other/Spark.cs @@ -14,8 +14,9 @@ namespace VRBeats public void Construct(Color c) { + float visibilityMultiplier = IsBlueDominant(c) ? 1.6f : 1.0f; materialBindings.SetUseEmmisiveIntensity(false); - materialBindings.SetEmmisiveColor(c * glowEffect); + materialBindings.SetEmmisiveColor(c * glowEffect * visibilityMultiplier); PlayAnimation(); } @@ -35,6 +36,11 @@ namespace VRBeats }).SetOwner(gameObject); ; } + private static bool IsBlueDominant(Color color) + { + return color.b > color.r && color.b >= color.g; + } + } } diff --git a/Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs b/Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs index 81d0afb..3e6770e 100644 --- a/Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs +++ b/Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs @@ -1,5 +1,4 @@ -using UnityEngine; -using Platinio.TweenEngine; +using UnityEngine; using TMPro; namespace VRBeats @@ -7,22 +6,25 @@ namespace VRBeats public class FinalScoreLabel : MonoBehaviour { [SerializeField] private TextMeshProUGUI scoreText = null; - [SerializeField] private float scoreFadeTime = 10.0f; [SerializeField] private int length = 10; private string initialValue = ""; private ScoreManager scoreManager = null; + private GameObject resultRoot = null; + private TextMeshProUGUI rankShadowText = null; + private TextMeshProUGUI rankDepthText = null; + private TextMeshProUGUI rankMainText = null; + private TextMeshProUGUI resultScoreText = null; + private TextMeshProUGUI resultComboText = null; private void Awake() { for (int n = 0; n < length; n++) - { initialValue += "0"; - } scoreManager = FindFirstObjectByType(); ApplyPopupTextStyle(); - + BuildResultLayout(); } public void ShowScore() @@ -30,39 +32,68 @@ namespace VRBeats if (scoreText == null || scoreManager == null) return; - PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , Mathf.Min(scoreFadeTime, 0.8f)).SetOnUpdateFloat(delegate (float v) + SetTitleActive(false); + gameObject.CancelAllTweens(); + + if (resultRoot != null) { - SetScore( (int)v ); - }).SetOnComplete(delegate + scoreText.gameObject.SetActive(false); + PopulateResultLayout(); + resultRoot.SetActive(true); + } + else { scoreText.text = scoreManager.BuildResultSummary(length); - }); + } } public void ResetValues() { gameObject.CancelAllTweens(); + + if (resultRoot != null) + resultRoot.SetActive(false); + if (scoreText != null) + scoreText.gameObject.SetActive(true); + + SetTitleActive(true); ApplyPopupTextStyle(); + if (scoreText != null) scoreText.text = initialValue; } - - private void SetScore(int score) + private void PopulateResultLayout() { - if (this.scoreText == null) + if (scoreManager == null || + rankShadowText == null || + rankDepthText == null || + rankMainText == null || + resultScoreText == null || + resultComboText == null) return; - string scoreText = score.ToString(); - int addLength = Mathf.Max( length - scoreText.Length , 0); - string addZeros = ""; - for (int n = 0; n < addLength; n++) - { - addZeros += "0"; - } + string rank = scoreManager.Rank; + Color mainColor = HexToColor(scoreManager.RankColorHex); + Color depthColor = HexToColor(GetRankDepthColorHex(rank)); - this.scoreText.text = addZeros + scoreText; + rankShadowText.text = rank; + rankDepthText.text = rank; + rankDepthText.color = depthColor; + rankMainText.text = rank; + rankMainText.color = mainColor; + resultScoreText.text = scoreManager.CurrentScore.ToString("N0"); + resultComboText.text = $"MAX COMBO {scoreManager.MaxCombo}"; + } + private void SetTitleActive(bool active) + { + Transform titleObj = scoreText != null + ? scoreText.rectTransform.parent?.Find("Title") + : null; + + if (titleObj != null) + titleObj.gameObject.SetActive(active); } private void ApplyPopupTextStyle() @@ -70,15 +101,110 @@ namespace VRBeats if (scoreText == null) return; - scoreText.enableAutoSizing = false; - scoreText.fontSize = 4.4f; - scoreText.alignment = TextAlignmentOptions.Center; - scoreText.overflowMode = TextOverflowModes.Overflow; + RectTransform rect = scoreText.rectTransform; + rect.anchorMin = new Vector2(0.5f, 0.5f); + rect.anchorMax = new Vector2(0.5f, 0.5f); + rect.anchoredPosition = new Vector2(0.0f, 0.0f); + rect.sizeDelta = new Vector2(620.0f, 250.0f); + + scoreText.enableAutoSizing = true; + scoreText.fontSizeMin = 1.2f; + scoreText.fontSizeMax = 5.5f; + scoreText.alignment = TextAlignmentOptions.MidlineLeft; + scoreText.overflowMode = TextOverflowModes.Truncate; scoreText.textWrappingMode = TextWrappingModes.NoWrap; - scoreText.lineSpacing = -18.0f; + scoreText.lineSpacing = -10.0f; scoreText.color = Color.white; scoreText.richText = true; } + private void BuildResultLayout() + { + if (scoreText == null) + return; + + Transform parent = scoreText.rectTransform.parent; + if (parent == null) + return; + + GameObject root = new GameObject("ResultLayoutRoot"); + root.transform.SetParent(parent, false); + RectTransform rootRect = root.AddComponent(); + rootRect.anchorMin = new Vector2(0.5f, 0.5f); + rootRect.anchorMax = new Vector2(0.5f, 0.5f); + rootRect.anchoredPosition = Vector2.zero; + rootRect.sizeDelta = new Vector2(620.0f, 250.0f); + root.SetActive(false); + resultRoot = root; + + // Rank badge left side — hierarchy order = draw order (shadow first, main on top) + rankShadowText = MakeTmpLabel(root.transform, "RankShadowText", + new Vector2(-166.0f, 6.0f), new Vector2(200.0f, 200.0f), 14.0f, + new Color(0.0f, 0.0f, 0.0f, 0.55f), TextAlignmentOptions.Midline); + rankDepthText = MakeTmpLabel(root.transform, "RankDepthText", + new Vector2(-168.0f, 8.0f), new Vector2(200.0f, 200.0f), 14.0f, + Color.white, TextAlignmentOptions.Midline); + rankMainText = MakeTmpLabel(root.transform, "RankMainText", + new Vector2(-170.0f, 10.0f), new Vector2(200.0f, 200.0f), 14.0f, + Color.white, TextAlignmentOptions.Midline); + + // Score and combo right side + resultScoreText = MakeTmpLabel(root.transform, "ResultScoreText", + new Vector2(70.0f, 35.0f), new Vector2(260.0f, 80.0f), 5.5f, + Color.white, TextAlignmentOptions.MidlineLeft); + resultComboText = MakeTmpLabel(root.transform, "ResultComboText", + new Vector2(70.0f, -35.0f), new Vector2(260.0f, 50.0f), 3.8f, + new Color(0.84f, 0.97f, 1.0f, 1.0f), TextAlignmentOptions.MidlineLeft); + } + + private TextMeshProUGUI MakeTmpLabel(Transform parent, string name, + Vector2 pos, Vector2 size, float fontSize, Color color, TextAlignmentOptions align) + { + GameObject go = new GameObject(name); + go.transform.SetParent(parent, false); + RectTransform rect = go.AddComponent(); + rect.anchorMin = new Vector2(0.5f, 0.5f); + rect.anchorMax = new Vector2(0.5f, 0.5f); + rect.anchoredPosition = pos; + rect.sizeDelta = size; + + TextMeshProUGUI tmp = go.AddComponent(); + tmp.fontSize = fontSize; + tmp.color = color; + tmp.alignment = align; + tmp.overflowMode = TextOverflowModes.Overflow; + tmp.textWrappingMode = TextWrappingModes.NoWrap; + tmp.raycastTarget = false; + + if (scoreText != null && scoreText.font != null) + { + tmp.font = scoreText.font; + tmp.fontSharedMaterial = scoreText.fontSharedMaterial; + } + + return tmp; + } + + private static string GetRankDepthColorHex(string rank) + { + switch (rank) + { + case "M": return "#7EEBFF"; + case "S+": return "#116BFF"; + case "S": return "#B56A16"; + case "A": return "#5CAA30"; + case "B": return "#B89E20"; + case "C": return "#C05A10"; + case "D": return "#B02040"; + default: return "#606870"; + } + } + + private static Color HexToColor(string hex) + { + if (ColorUtility.TryParseHtmlString(hex, out Color color)) + return color; + return Color.white; + } } } diff --git a/Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs b/Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs index 1f531cd..e7ae754 100644 --- a/Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs +++ b/Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs @@ -29,12 +29,13 @@ namespace VRBeats [SerializeField] private bool createMissingHudLabels = true; [SerializeField] private bool applyHudPlacement = true; [SerializeField] private Vector2 hudAnchoredPosition = new Vector2(0.0f, 1.65f); - [SerializeField] private float perfectWindow = 0.08f; - [SerializeField] private float greatWindow = 0.15f; - [SerializeField] private float goodWindow = 0.25f; + [SerializeField] private float perfectWindow = 0.11f; + [SerializeField] private float greatWindow = 0.20f; + [SerializeField] private float goodWindow = 0.32f; private int maxMultiplier = 0; private const int MaxCourseScore = 1000000; + private const float ProgressBarWidth = 150.0f; private float currentMultiplier = 1.0f; private int acumulateErrors = 0; private int errorLimit = 0; @@ -54,6 +55,8 @@ namespace VRBeats private float judgementTimer = 0.0f; private Text progressLabel = null; private Text rankLabel = null; + private Image progressBarBackground = null; + private Image progressBarFill = null; private Vector3 comboBaseScale = Vector3.one; private float songCurrentTime = 0.0f; private float songDuration = 0.0f; @@ -62,8 +65,20 @@ namespace VRBeats private static bool hasPendingSliceTiming = false; private static float pendingSliceTiming = 0.0f; + private static Font hudFont = null; + private Image ringBackground = null; - public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f); + public int CurrentScore + { + get + { + float accuracyRatio = AccuracyPercent / 100.0f; + float comboRatio = totalNoteCount > 0 + ? maxCombo / (float)totalNoteCount + : 0.0f; + return Mathf.RoundToInt(800000.0f * accuracyRatio + 200000.0f * comboRatio); + } + } public float AccuracyPercent { get @@ -80,6 +95,8 @@ namespace VRBeats { get { + if (CurrentScore >= MaxCourseScore) return "M"; + float accuracy = AccuracyPercent; if (accuracy >= 98.0f) return "S+"; if (accuracy >= 95.0f) return "S"; @@ -91,6 +108,9 @@ namespace VRBeats } } + public int MaxCombo => maxCombo; + public string RankColorHex => GetRankColorHex(); + private void Awake() { maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier; @@ -99,6 +119,7 @@ namespace VRBeats if (multiplierLoader != null) multiplierLoader.fillAmount = 0.0f; + canvasGroup ??= GetComponent() ?? gameObject.AddComponent(); PrepareHud(); } @@ -142,15 +163,23 @@ namespace VRBeats public void SetSongProgress(float currentTime, float duration) { - songCurrentTime = Mathf.Max(0.0f, currentTime); songDuration = Mathf.Max(0.0f, duration); + songCurrentTime = songDuration > 0.0f + ? Mathf.Clamp(currentTime, 0.0f, songDuration) + : Mathf.Max(0.0f, currentTime); } public void OnGameOver() { gameObject.CancelAllTweens(); if (canvasGroup != null) - canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); + { + canvasGroup.alpha = 0.0f; + canvasGroup.interactable = false; + canvasGroup.blocksRaycasts = false; + } + + SetSaberVisibility(false); } public void OnGameRestart() @@ -158,7 +187,13 @@ namespace VRBeats ResetThisComponent(); gameObject.CancelAllTweens(); if (canvasGroup != null) - canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); + { + canvasGroup.alpha = 1.0f; + canvasGroup.interactable = true; + canvasGroup.blocksRaycasts = false; + } + + SetSaberVisibility(true); } public void ResetThisComponent() @@ -220,17 +255,11 @@ namespace VRBeats public string BuildResultSummary(int minScoreLength) { - string score = CurrentScore.ToString(); - score = new string('0', Mathf.Max(minScoreLength - score.Length, 0)) + score; - string badge = missCount == 0 - ? (greatCount == 0 && goodCount == 0 ? "PERFECT PLAY" : "FULL COMBO") - : "TRY AGAIN"; + string score = CurrentScore.ToString("N0"); - return $"{Rank}\n" + - $"{score}\n" + - $"ACC {AccuracyPercent:0.0}% {badge}\n" + - $"MAX COMBO {maxCombo}\n" + - $"P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}"; + return $"{Rank}" + + $"{score}\n" + + $"MAX COMBO {maxCombo}"; } private void CancelTweenById(int id) @@ -277,8 +306,8 @@ namespace VRBeats scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}"; if (comboLabel != null) comboLabel.text = currentCombo > 0 - ? $"COMBO\n{currentCombo}" - : "COMBO\n0"; + ? $"COMBO\n{currentCombo}" + : "COMBO\n0"; if (accuracyLabel != null) accuracyLabel.text = $"{AccuracyPercent:0.0}%"; if (rankLabel != null) @@ -287,6 +316,10 @@ namespace VRBeats progressLabel.text = songDuration > 0.0f ? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}" : ""; + if (progressBarFill != null) + SetProgressBarFill(songDuration > 0.0f + ? Mathf.Clamp01(songCurrentTime / songDuration) + : 0.0f); if (judgementLabel == null) return; @@ -326,13 +359,13 @@ namespace VRBeats else if (judgement == BeatJudgement.Great) { greatCount++; - earnedAccuracyPoints += 700; + earnedAccuracyPoints += 900; currentCombo++; } else if (judgement == BeatJudgement.Good) { goodCount++; - earnedAccuracyPoints += 400; + earnedAccuracyPoints += 700; currentCombo++; } else @@ -351,10 +384,10 @@ namespace VRBeats private static float GetComboMultiplier(int combo) { - if (combo >= 200) return 1.5f; - if (combo >= 100) return 1.35f; - if (combo >= 50) return 1.2f; - if (combo >= 20) return 1.1f; + if (combo >= 50) return 1.5f; + if (combo >= 30) return 1.35f; + if (combo >= 15) return 1.2f; + if (combo >= 5) return 1.1f; return 1.0f; } @@ -383,11 +416,11 @@ namespace VRBeats private float GetComboTierProgress() { int lower = 0; - int upper = 20; - if (currentCombo >= 200) return 1.0f; - if (currentCombo >= 100) { lower = 100; upper = 200; } - else if (currentCombo >= 50) { lower = 50; upper = 100; } - else if (currentCombo >= 20) { lower = 20; upper = 50; } + int upper = 5; + if (currentCombo >= 50) return 1.0f; + if (currentCombo >= 30) { lower = 30; upper = 50; } + else if (currentCombo >= 15) { lower = 15; upper = 30; } + else if (currentCombo >= 5) { lower = 5; upper = 15; } return Mathf.InverseLerp(lower, upper, currentCombo); } @@ -398,22 +431,54 @@ namespace VRBeats if (applyHudPlacement && rect != null) rect.anchoredPosition = hudAnchoredPosition; - ConfigureText(scoreLabel, new Vector2(-255.0f, -18.0f), new Vector2(220.0f, 40.0f), 26, Color.white, TextAnchor.MiddleLeft); - ConfigureText(multiplierLabel, new Vector2(255.0f, 36.0f), new Vector2(100.0f, 68.0f), 34, Color.white, TextAnchor.MiddleCenter); - ConfigureImage(multiplierLoader, new Vector2(255.0f, 36.0f), new Vector2(104.0f, 104.0f)); + comboLabel = comboLabel != null ? comboLabel : FindHudText("Combo"); + accuracyLabel = accuracyLabel != null ? accuracyLabel : FindHudText("Accuracy"); + rankLabel = rankLabel != null ? rankLabel : FindHudText("Rank"); + judgementLabel = judgementLabel != null ? judgementLabel : FindHudText("Judgement"); + progressLabel = progressLabel != null ? progressLabel : FindHudText("SongProgress"); + progressBarBackground = progressBarBackground != null ? progressBarBackground : FindHudImage("SongProgressBarBackground"); + progressBarFill = progressBarFill != null ? progressBarFill : FindHudImage("SongProgressBarFill"); if (!createMissingHudLabels) return; - comboLabel ??= CreateHudText("Combo", new Vector2(-255.0f, 74.0f), new Vector2(220.0f, 112.0f), 36, Color.white, TextAnchor.MiddleLeft); - accuracyLabel ??= CreateHudText("Accuracy", new Vector2(-255.0f, -58.0f), new Vector2(220.0f, 34.0f), 20, new Color(0.88f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleLeft); - rankLabel ??= CreateHudText("Rank", new Vector2(-255.0f, -105.0f), new Vector2(220.0f, 76.0f), 48, Color.white, TextAnchor.MiddleLeft); - judgementLabel ??= CreateHudText("Judgement", new Vector2(0.0f, 112.0f), new Vector2(260.0f, 54.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter); - progressLabel ??= CreateHudText("SongProgress", new Vector2(255.0f, -62.0f), new Vector2(180.0f, 34.0f), 18, Color.white, TextAnchor.MiddleCenter); + comboLabel ??= CreateHudText("Combo"); + accuracyLabel ??= CreateHudText("Accuracy"); + rankLabel ??= CreateHudText("Rank"); + judgementLabel ??= CreateHudText("Judgement"); + progressLabel ??= CreateHudText("SongProgress"); + progressBarBackground ??= CreateHudImage("SongProgressBarBackground"); + progressBarFill ??= CreateHudImage("SongProgressBarFill"); + ringBackground = ringBackground != null ? ringBackground : FindHudImage("MultiplierRingBg"); + ringBackground ??= CreateHudImage("MultiplierRingBg"); + + ConfigureText(comboLabel, new Vector2(-335.0f, 92.0f), new Vector2(190.0f, 118.0f), 34, Color.white, TextAnchor.MiddleCenter); + ConfigureText(scoreLabel, new Vector2(-335.0f, 10.0f), new Vector2(190.0f, 42.0f), 22, Color.white, TextAnchor.MiddleCenter); + ConfigureText(accuracyLabel, new Vector2(-335.0f, -34.0f), new Vector2(190.0f, 32.0f), 18, new Color(0.84f, 0.94f, 1.0f, 1.0f), TextAnchor.MiddleCenter); + ConfigureText(rankLabel, new Vector2(-335.0f, -92.0f), new Vector2(190.0f, 72.0f), 48, Color.white, TextAnchor.MiddleCenter); + ConfigureText(judgementLabel, new Vector2(0.0f, 118.0f), new Vector2(280.0f, 56.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter); + ConfigureText(multiplierLabel, new Vector2(335.0f, 38.0f), new Vector2(118.0f, 76.0f), 34, Color.white, TextAnchor.MiddleCenter); + ConfigureText(progressLabel, new Vector2(335.0f, -75.0f), new Vector2(180.0f, 30.0f), 17, Color.white, TextAnchor.MiddleCenter); + ConfigureImage(multiplierLoader, new Vector2(335.0f, 38.0f), new Vector2(112.0f, 112.0f), new Color(1.0f, 1.0f, 1.0f, 0.78f)); + ConfigureImage(ringBackground, new Vector2(335.0f, 38.0f), new Vector2(112.0f, 112.0f), new Color(1.0f, 1.0f, 1.0f, 0.15f)); + if (ringBackground != null && multiplierLoader != null) + { + ringBackground.sprite = multiplierLoader.sprite; + ringBackground.type = Image.Type.Simple; + ringBackground.transform.SetSiblingIndex(multiplierLoader.transform.GetSiblingIndex()); + } + ConfigureImage(progressBarBackground, new Vector2(335.0f, -48.0f), new Vector2(ProgressBarWidth, 5.0f), new Color(1.0f, 1.0f, 1.0f, 0.22f)); + ConfigureImage(progressBarFill, new Vector2(335.0f - ProgressBarWidth * 0.5f, -48.0f), new Vector2(ProgressBarWidth, 5.0f), Color.white); + if (progressBarFill != null) + { + RectTransform fillRect = progressBarFill.rectTransform; + fillRect.pivot = new Vector2(0.0f, 0.5f); + SetProgressBarFill(0.0f); + } comboBaseScale = comboLabel != null ? comboLabel.transform.localScale : Vector3.one; } - private Text CreateHudText(string name, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment) + private Text CreateHudText(string name) { GameObject textObject = new GameObject(name); textObject.layer = gameObject.layer; @@ -422,15 +487,27 @@ namespace VRBeats RectTransform rect = textObject.AddComponent(); textObject.AddComponent(); Text text = textObject.AddComponent(); - ConfigureText(text, anchoredPosition, size, fontSize, color, alignment); return text; } + private Image CreateHudImage(string name) + { + GameObject imageObject = new GameObject(name); + imageObject.layer = gameObject.layer; + imageObject.transform.SetParent(transform, false); + + imageObject.AddComponent(); + return imageObject.AddComponent(); + } + private static void ConfigureText(Text text, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment) { if (text == null) return; + if (text.font == null) + text.font = HudFont; + RectTransform rect = text.rectTransform; rect.anchorMin = new Vector2(0.5f, 0.5f); rect.anchorMax = new Vector2(0.5f, 0.5f); @@ -452,6 +529,11 @@ namespace VRBeats } private static void ConfigureImage(Image image, Vector2 anchoredPosition, Vector2 size) + { + ConfigureImage(image, anchoredPosition, size, new Color(1.0f, 1.0f, 1.0f, 0.85f)); + } + + private static void ConfigureImage(Image image, Vector2 anchoredPosition, Vector2 size, Color color) { if (image == null) return; @@ -461,16 +543,49 @@ namespace VRBeats rect.anchorMax = new Vector2(0.5f, 0.5f); rect.anchoredPosition = anchoredPosition; rect.sizeDelta = size; - image.color = new Color(1.0f, 1.0f, 1.0f, 0.85f); + image.color = color; image.raycastTarget = false; } + private void SetProgressBarFill(float progress) + { + if (progressBarFill == null) + return; + + RectTransform rect = progressBarFill.rectTransform; + rect.sizeDelta = new Vector2(ProgressBarWidth * Mathf.Clamp01(progress), rect.sizeDelta.y); + } + + private static Font HudFont + { + get + { + if (hudFont == null) + hudFont = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + + return hudFont; + } + } + + private Text FindHudText(string objectName) + { + Transform child = transform.Find(objectName); + return child != null ? child.GetComponent() : null; + } + + private Image FindHudImage(string objectName) + { + Transform child = transform.Find(objectName); + return child != null ? child.GetComponent() : null; + } + private string GetRankColorHex() { switch (Rank) { + case "M": return "#E8B7FF"; case "S+": return "#41F2FF"; - case "S": return "#69FFD1"; + case "S": return "#FFD95C"; case "A": return "#B9FF72"; case "B": return "#FFE06A"; case "C": return "#FFB15C"; @@ -496,5 +611,20 @@ namespace VRBeats int remainingSeconds = wholeSeconds % 60; return $"{minutes}:{remainingSeconds:00}"; } + + private static void SetSaberVisibility(bool visible) + { + VR_Saber[] sabers = FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < sabers.Length; i++) + { + if (sabers[i] == null) + continue; + + if (visible) + sabers[i].MakeVisible(); + else + sabers[i].MakeInvisible(); + } + } } } diff --git a/Assets/VRBeatsKit/Settings/Settings.asset b/Assets/VRBeatsKit/Settings/Settings.asset index 9e46c28..3c9f82b 100644 --- a/Assets/VRBeatsKit/Settings/Settings.asset +++ b/Assets/VRBeatsKit/Settings/Settings.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7aabf7bc54d695644952b5c737f1c915, type: 3} m_Name: Settings m_EditorClassIdentifier: - rightColor: {r: 0, g: 0.6002884, b: 1, a: 1} + rightColor: {r: 0.03, g: 0.32, b: 1, a: 1} leftColor: {r: 1, g: 0, b: 0, a: 1} glowIntensity: 40 targetTravelDistance: 40 diff --git a/CLAUDE_REVIEW_HUD_PROPOSAL.md b/CLAUDE_REVIEW_HUD_PROPOSAL.md new file mode 100644 index 0000000..8e18587 --- /dev/null +++ b/CLAUDE_REVIEW_HUD_PROPOSAL.md @@ -0,0 +1,90 @@ +# Claude Review Request: Game Scene HUD Polish + +## Goal + +Match the Game scene HUD closer to the provided Beat Saber-style reference: + +- Left side: combo, score, accuracy, current rank +- Right side: multiplier ring and remaining/elapsed song time +- Keep the center lane clear for notes and sabers +- Use thin, readable white/cyan text with minimal visual noise + +## Current Scene Findings + +Unity bridge reports active scene: + +- Scene: `Game` +- Playing: `true` +- Main HUD root: `_UI/ScoreCanvas` +- Existing HUD items: + - `_UI/ScoreCanvas/Combo` + - `_UI/ScoreCanvas/Score` + - `_UI/ScoreCanvas/Accuracy` + - `_UI/ScoreCanvas/Rank` + - `_UI/ScoreCanvas/Multiplier` + - `_UI/ScoreCanvas/Image` + - `_UI/ScoreCanvas/SongProgress` + - `_UI/ScoreCanvas/Judgement` + +Current `ScoreCanvas` world placement: + +- Position: approximately `(5.8, 2.4, 17.8)` +- Rotation: approximately `(354.8, 18.7, 0)` +- Scale: `(0.005, 0.005, 0.005)` + +## Proposed Direction + +Prefer scene/UI layout polish first. Avoid gameplay logic changes. + +1. Split HUD into stable left and right visual groups under `_UI/ScoreCanvas`. +2. Left group layout: + - `COMBO` + - combo number, larger + - score + - accuracy percent + - rank, larger +3. Right group layout: + - circular multiplier ring + - multiplier value centered, e.g. `x4` + - small time bar below + - elapsed / total or remaining time text +4. Move `Judgement` away from persistent HUD, likely near center-top or temporarily shown and faded. +5. Keep HUD outside the note highway and saber swing area. + +## Suggested Implementation Options + +### Option A: Scene-only first + +Adjust Game scene RectTransforms / world canvas placement and existing child positions without changing code. + +Pros: +- Lowest risk +- Fast to evaluate visually in Unity +- Matches user request to polish Game scene first + +Cons: +- Existing `ScoreManager.PrepareHud()` may overwrite some RectTransform values at runtime if `applyHudPlacement` or label setup runs. + +Estimated score: 78/100 unless we confirm code does not overwrite the scene layout. + +### Option B: Minimal code-supported layout + +Make `ScoreManager` expose a stable Beat Saber HUD layout preset and only adjust positions/sizes of existing labels. + +Pros: +- Runtime layout stays consistent +- Existing labels and score data remain unchanged +- Low gameplay risk + +Cons: +- Requires code edit, so must pass the 80+ review gate. + +Estimated score: 86/100 if scoped only to HUD placement/style and verified in Play mode. + +## Codex Recommendation + +Start with Option A in Unity scene if possible. If `ScoreManager.PrepareHud()` overwrites the layout during play, move to Option B with a narrow code change. + +Codex preliminary score for Option B: 86/100. + +Claude Code: please review this proposal and either approve with score >= 80 or suggest revisions before code changes proceed. diff --git a/COLLABORATION_RULES.md b/COLLABORATION_RULES.md new file mode 100644 index 0000000..196153a --- /dev/null +++ b/COLLABORATION_RULES.md @@ -0,0 +1,20 @@ +# Collaboration Rules + +When changing code in this project, Codex and Claude Code should use a mutual review gate before proceeding. + +## Code Change Gate + +1. Before editing code, state the intended change and expected impact. +2. Share feedback between Codex and Claude Code when both are active. +3. Score the proposed change out of 100 based on: + - Correctness + - Scope control + - Risk to current Unity scenes and runtime behavior + - Maintainability + - Test or verification plan +4. Proceed only when the agreed score is 80 or higher. +5. If the score is below 80, revise the approach first. + +## Current User Preference + +For now, focus on polishing the Unity Game scene. Do not change code unless the review gate above is satisfied. diff --git a/HANDOFF.md b/HANDOFF.md index 236c3b2..d6c20a5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,570 +1,220 @@ -# VR Beat Saber 프로젝트 인수인계 문서 +# VR Beat Saber 프로젝트 인수인계 -## 개요 +## 현재 상태 -Meta Quest용 VR Beat Saber 클론. Beat Sage API로 노래를 자동 채보하고, Synology NAS에 업로드/다운로드한다. -이 문서는 **기존 프로젝트(구버전)를 VRBeatsKit 기반 새 프로젝트로 이전**하기 위한 인수인계 자료다. +- Unity: `6000.3.12f1` +- 브랜치: `master` +- 원격 저장소: `origin = https://whdwo798.synology.me/whdwo798/BeatSaber.git` +- 현재 주요 작업 초점: `Assets/Scenes/Game.unity` 게임 화면, HUD, 점수/랭크, 결과 화면, 노트 체감 개선 ---- +## 협업 규칙 -## 현재 상태 (2026-05-28) +- 코드 변경 전 Codex와 Claude Code가 변경 방향을 서로 검토한다. +- 100점 만점 기준 80점 이상이면 진행한다. +- 세부 규칙은 `COLLABORATION_RULES.md`에 정리되어 있다. -현재 저장소는 VRBeatsKit 기반 프로젝트 위에 커스텀 곡 선택/생성/NAS 연동, 360도 영상 배경, 점수/콤보 개편, 싱크 보정 화면, VR UI 포인터 보정, 세이버/큐브 트레일 효과를 붙인 상태다. +## Unity / MCP 브릿지 -- Unity 버전: `6000.3.12f1` -- 현재 브랜치: `master` -- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git` -- `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개 -- NAS 비밀번호/세션 파일은 로컬 전용이다. `env`, `cookies.txt`, `Assets/StreamingAssets/nas_config.json`, Unity `_Recovery`는 커밋하지 않는다. -- Unity Codex Bridge는 에디터 내부 HTTP 브리지와 MCP 서버를 통해 씬/로그/캡처를 조회하는 용도다. 포트 충돌 시 Unity 재시작 또는 기존 포트 점유 프로세스 정리가 필요하다. +- `Assets/Editor/UnityCodexBridgeServer.cs` + - 기본 포트 충돌 시 `19744-19748` 범위에서 fallback 하도록 수정했다. +- `tools/unity-mcp-server/index.mjs` + - `UNITY_BRIDGE_URL`이 없으면 Unity 브릿지 포트를 자동 탐색한다. +- 최근 사용 포트는 `19745`였다. +- Unity 로그에는 `Cannot find parent for ...` / UIElements RenderChain 로그가 반복될 수 있는데, 현재 확인된 범위에서는 게임 코드 컴파일 오류가 아니라 에디터 UI 쪽 로그로 보인다. -### 실제 씬 구성 +## Game 씬 HUD -현재 Build Settings는 아래 순서다. +### ScoreCanvas 배치 -1. `Assets/VRBeatsKit/Scenes/Menu.unity` -2. `Assets/VRBeatsKit/Scenes/BoxingStyle.unity` -3. `Assets/Scenes/SongCreator.unity` -4. `Assets/VRBeatsKit/Scenes/SaberStyle.unity` -5. `Assets/Scenes/Game.unity` +`Assets/Scenes/Game.unity` -문서 아래쪽에 남아 있는 `Intro -> SongSelect -> Game -> SongCreator` 흐름은 목표 설계에 가깝다. 현재 실제 진입점은 VRBeatsKit `Menu.unity`이며, 그 안의 `SongSelect` 패널이 커스텀 곡 선택 UI 역할을 한다. +- `ScoreCanvas` 위치를 플레이어 앞쪽으로 당겼다. + - Z: `17.8 -> 5` + - Scale: `0.005 -> 0.006` + - `applyHudPlacement`: `true -> false` +- HUD가 중앙에 겹치던 문제를 줄이고, 좌우로 분리되어 보이게 했다. -### 현재 구현된 주요 흐름 +### ScoreManager HUD 생성/배치 + +`Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs` + +- 왼쪽 HUD: + - `COMBO` + - score + - accuracy + - rank +- 오른쪽 HUD: + - multiplier text + - multiplier ring background + - song progress bar + - current time / total time +- 동적 생성 Text에는 `LegacyRuntime.ttf`를 lazy 로딩해서 폰트가 비어 렌더링되지 않는 문제를 막았다. +- `Resources.GetBuiltinResource`는 static initializer에서 호출하지 않도록 수정했다. +- 진행 바는 `Image.fillAmount` 대신 RectTransform 폭을 직접 조절해서 더 안정적으로 표시한다. + +## 노래 시간 / 완료 처리 + +`Assets/VRBeatsKit/Scripts/Core/AudioManager.cs` + +- `AudioSource.time` 접근 시 Unity가 `resource that is not a clip` 경고를 내던 문제를 피하기 위해 DSP 기반 시간을 우선 사용한다. + +`Assets/Script/SongController.cs` + +- MP3 `clip.length`가 0으로 잡힐 때를 대비해 fallback duration을 계산한다. + - `song.duration` + - 마지막 노트 시간 + 1초 +- 결과 완료 대기에는 `clip.length`가 아니라 `_clipLength`를 사용한다. +- 이 수정으로 게임 도중 갑자기 결과창이 뜨는 문제를 줄였다. + +## 노트 위치 체감 + +`Assets/Script/SongController.cs` + +- 낮은 큐브가 너무 바닥에 깔려 베기 어려운 문제를 줄였다. +- Y 레이어 매핑 변경: ```text -Menu.unity / SongSelect - -> DownloadManager가 NAS 정적 서버의 songs.json 로드 - -> SongDetailPanel에서 곡/난이도 다운로드 - -> GameSession.SelectedSong / SelectedDifficulty 설정 - -> Game.unity 로드 +이전: +lineLayer 0: -0.38 +lineLayer 1: 0.00 +lineLayer 2: +0.38 -Game.unity - -> SongController가 temporaryCachePath의 mp3 + map json 로드 - -> VRBeats.AudioManager로 음악 재생 - -> 오디오 시간 기준으로 VR_BeatManager.Spawn() 호출 - -> VR_BeatCube / Cuttable / DamageSaber가 색상, 방향, 속도 판정 - -SongCreator.unity - -> SongCreatorManager가 로컬 mp3 또는 직접 mp3 URL 입력 - -> BeatSageUploader가 Beat Sage 요청/폴링/ZIP 다운로드 - -> BeatSageConverter가 .dat를 NoteData로 변환 - -> NasPublisher가 mp3, map json, songs.json을 Synology NAS에 업로드 +현재: +lineLayer 0: -0.12 +lineLayer 1: +0.22 +lineLayer 2: +0.56 ``` -### 2026-05-28 최근 반영된 변경 +- 적용값: -#### 게임 플레이/노트/점수 +```csharp +private const float LayerSpacing = 0.34f; +private const float VerticalOffset = 0.22f; +``` -- `Assets/Script/SongController.cs` - - 큐브가 중간에 멈추거나 급가속하는 느낌을 줄이기 위해 노트를 일정 속도로 접근시키는 흐름으로 조정했다. - - 노트 도착 기준을 오디오 시간과 맞추고, 난이도별 노트 수가 적어도 랭크가 과도하게 낮게 나오지 않도록 총 노트 기준 점수/랭크 구조와 맞물리게 했다. -- `Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`, `FinalScoreLabel.cs` - - DJMAX 계열에 가까운 콤보/정확도 중심 점수 구조로 개편했다. - - 총 노트 수 기준으로 정확도와 랭크를 계산하므로 Normal처럼 노트 수가 적은 곡도 구조적으로 F에 고정되지 않는다. - - 재시작/리플레이 시 이전 점수, 콤보, 음악 상태가 남지 않도록 초기화 흐름을 보강했다. -- `Assets/VRBeatsKit/Scripts/Core/VR_BeatCube.cs`, `Cuttable.cs`, `DamageSaber.cs` - - 정상 절단된 큐브가 뒤로 날아간 뒤 Miss로 다시 잡히는 문제를 막기 위해 절단/미스 상태 흐름을 보강했다. - - 방향/색상/속도 판정과 절단 시각 효과가 엇갈리지 않도록 유효 절단 조건을 정리했다. -- `Assets/VRBeatsKit/Scripts/Spawneable/SpawnEventInfo.cs`, `VR_BeatManager.cs` - - 노트 스폰 타이밍과 이동 속도 보정에 필요한 정보를 전달하도록 확장했다. +## 점수 / 랭크 / 콤보 배율 -#### VR UI/입력 +`Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs` -- `Assets/Script/VRPointerController.cs`, `VRPointerSetup.cs` - - VR 컨트롤러 레이 버튼 클릭 안정성을 보강했다. - - 비활성 Canvas의 버튼이 레이캐스트 후보에 남아 클릭을 빼앗는 문제를 필터링했다. - - SongSelect 스크롤 속도를 올리고, 검지 트리거를 누른 상태로 위/아래 드래그해 스크롤할 수 있게 했다. - - 메뉴/게임 씬 전환 후에도 포인터가 다시 주입되도록 유지했다. -- `Assets/VRBeatsKit/Prefabs/PlayerSetup/MenuPlayer.prefab`, `SaberStylePlayer.prefab`, `VR_InteractorController.cs` - - XR Interaction Toolkit의 `XRRayInteractor`/`XRInteractorLineVisual`과 커스텀 포인터가 겹쳐 버튼 입력이 꼬이는 문제를 막기 위해 XRI 레이 시각화를 비활성화했다. - - `Missing ILineRenderable / Ray Interactor component` 오류가 반복되지 않도록 런타임 enable 흐름도 커스텀 포인터 기준으로 정리했다. +### 점수 구조 -#### 비주얼/배경/세이버 +- 총점 최대: `1,000,000` +- 정확도 점수: `800,000` +- 콤보 보너스: `200,000` -- `Assets/Script/Game360VideoBackground.cs`, `Assets/Scenes/Game.unity` - - 게임 씬 배경을 360도 영상처럼 둘러싼 내부 구체/스카이박스형 배경으로 재생하도록 추가했다. - - 영상은 Unity 호환 H.264 baseline/CFR MP4로 변환해 쓰는 것을 권장한다. 원본에 timestamp warning이 있으면 `ffmpeg`로 재인코딩한다. +```text +CurrentScore = 800000 * accuracyRatio + 200000 * comboRatio +``` + +### 판정 가중치 + +```text +Perfect = 1000 +Great = 900 +Good = 700 +Miss = 0 +``` + +### 판정 시간 + +```text +Perfect <= 0.11s +Great <= 0.20s +Good <= 0.32s +``` + +### 콤보 배율 + +초보자도 빨리 보상을 느끼도록 조정했다. + +```text +0~4 combo x1.0 +5~14 combo x1.1 +15~29 combo x1.2 +30~49 combo x1.35 +50+ combo x1.5 +``` + +Multiplier ring 진행도도 같은 구간 기준으로 맞췄다. + +### 랭크 + +- `M`: 최종 점수 `1,000,000` 이상 +- 그 외 랭크는 `AccuracyPercent` 기준 + +```text +S+ >= 98% +S >= 95% +A >= 90% +B >= 80% +C >= 70% +D >= 60% +F < 60% +``` + +### 랭크 색상 + +```text +M #E8B7FF 보석/프리즘 느낌 +S+ #41F2FF 청록 네온 +S #FFD95C 골드 +A #B9FF72 +B #FFE06A +C #FFB15C +D #FF7C7C +F #A9B7C0 +``` + +## 결과 화면 + +`Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs` + +- 기존 `scoreText` 하나에 rich text로 모든 결과를 넣는 방식에서, 결과 전용 레이아웃을 런타임 생성하는 방식으로 변경했다. +- `ShowScore()`에서 기존 `Title`을 숨기고, 원래 `scoreText`를 비활성화한 뒤 결과 전용 레이아웃을 보여준다. +- `ResetValues()`에서 결과 레이아웃을 숨기고 `Title`과 `scoreText`를 복원한다. + +동적 생성 구조: + +```text +ResultLayoutRoot + ├── RankShadowText + ├── RankDepthText + ├── RankMainText + ├── ResultScoreText + └── ResultComboText +``` + +- `RankShadowText`, `RankDepthText`, `RankMainText`를 겹쳐 랭크가 더 입체적으로 보이도록 했다. +- `scoreText.font`와 `scoreText.fontSharedMaterial`을 복사해서 런타임 생성 TMP가 렌더링되지 않는 문제를 방지했다. +- `M`은 연보라 main + 시안 depth로 보석 느낌을 주고, `S+`는 청록, `S`는 골드 계열로 구분한다. + +## 비주얼 / 세이버 / 파티클 + +### 파란색 가시성 + +- `Assets/VRBeatsKit/Settings/Settings.asset` - `Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs` - - 기존 검끝 한 점 `TrailRenderer` 방식 대신, 세이버 `Start-End` 검신 전체를 샘플링하는 월드 스페이스 리본 메쉬 잔상으로 교체했다. - - 검끝에서만 빙글 도는 헬리콥터 같은 잔상 대신, 검신이 휘둘린 면이 짧게 남는 형태다. - `Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs` - - 큐브가 잘릴 때 절단면/파편 쪽에서 짧은 트레일이 남도록 별도 효과를 추가했다. -- `Assets/VRBeatsKit/Settings/Settings.asset`, `DefaultVolumeProfile.asset` - - 큐브 네온/블룸 강도를 낮춰 과한 발광을 줄였다. -- `Assets/VRBeatsKit/Prefabs/VR_Saber/2/RightFuturisticSword.prefab` - - 두 번째 세이버도 너무 수직으로 서지 않도록 프리팹 회전을 보정했다. +- `Assets/VRBeatsKit/Scripts/Other/Spark.cs` -#### 싱크 보정/개발 도구 +파란색 세이버/트레일/스파크가 너무 연해서 잘 보이지 않던 문제를 줄였다. -- `Assets/Script/GlobalSyncSettings.cs`, `SyncCalibrationOverlay.cs`, `MenuSyncButtonInjector.cs` - - 메뉴의 Create Song 버튼 아래에 Sync 버튼을 주입하고, 별도 싱크 보정 화면을 열 수 있게 했다. - - 주기적인 틱 사운드/시각 기준을 보고 오른쪽 컨트롤러 A 버튼 또는 키보드로 입력 지연을 기록하는 구조다. - - TextMeshPro 한글 깨짐을 줄이기 위해 `TMP Settings.asset`에 NanumGothic fallback을 추가했다. -- `Assets/Editor/UnityCodexBridgeServer.cs`, `tools/unity-mcp-server/`, `docs/unity_mcp_bridge.md` - - Codex가 Unity 에디터의 health/log/scene/capture/play state를 조회할 수 있는 로컬 브리지와 MCP 서버를 추가했다. - - 현재 MCP 연결은 세션/포트 상태에 따라 재시작이 필요할 수 있다. +### 결과 팝업 / 사버 표시 -#### NAS/다운로드/생성 +- 게임오버/결과 시 세이버 표시 상태를 정리해 결과 화면이 더 읽기 쉽도록 했다. -- `Assets/Script/NasPublisher.cs` - - `nas_config.json` 또는 로컬 `env` 기반으로 NAS 계정/비밀번호를 읽도록 정리했다. - - DSM 응답 파싱 실패/비밀번호 누락/URL 공백 문제를 더 명확히 로그로 남기도록 보강했다. -- `Assets/Script/DownloadManager.cs`, `SongLibrary.cs` - - 곡이 실제로 다운로드되는 경로를 로그와 상태로 확인할 수 있게 했다. - - 곡 삭제 시 mp3/map json/다운로드 상태가 같이 지워지도록 정리해 캐시가 쌓이기만 하는 문제를 줄였다. -- `Assets/Script/BeatSageConverter.cs`, `BeatSageUploader.cs` - - Beat Sage 변환 결과와 노트 수 로그를 확인하기 쉽게 유지했다. +## 주의 사항 -#### 로컬 전용/커밋 제외 +- `dotnet build`는 Unity 패키지 참조를 일반 .NET 빌드가 못 찾는 경우가 있어 신뢰도가 낮다. Unity 에디터 컴파일 로그와 Play 모드 확인을 우선한다. +- `Library/ScriptAssemblies/Assembly-CSharp.dll` timestamp가 갱신되지 않으면 Unity가 아직 스크립트를 리로드하지 않은 상태일 수 있다. +- 결과 화면 레이아웃은 실제 Play 화면에서 `M`, `S+`, `1,000,000`, 긴 `MAX COMBO` 케이스로 크기/위치를 확인해야 한다. +- Unity Console의 UIElements RenderChain 로그는 현재 작업한 게임 코드와 직접 연관된 컴파일 오류로 보이지 않는다. -- `env`, `cookies.txt`, `Assets/_Recovery/`는 로컬 전용이라 커밋하지 않는다. -- `tools/unity-mcp-server/node_modules/`도 커밋 제외다. +## 다음 확인 권장 -### 2026-05-26 이전 반영된 변경 - -- `Assets/Script/SongController.cs` - - Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다. - - 기존 라인 x 좌표는 대략 `-0.375, -0.125, 0.125, 0.375`였고, 현재는 `-0.63, -0.21, 0.21, 0.63`이다. - - 맵 노트 정렬을 `time -> position -> lineLayer` 순서로 바꿔 같은 시간대 노트 처리 순서를 안정화했다. -- 전체 C# 경고 제거 - - `FindObjectOfType`, `FindObjectsOfType`, `InputHelpers`, `TMP_Text.enableWordWrapping`, `EditorApplication.currentScene`, 구버전 `PlayerSettings` API를 최신 API로 교체했다. - - 미사용 필드/변수, 상속 멤버 숨김, Unity 메시지 시그니처 경고를 정리했다. - - 최종 확인 빌드: `dotnet build VRBeatSaber.slnx --no-incremental` = 경고 0개, 오류 0개. -- `Assets/Script/VRPointerController.cs`, `VRPointerSetup.cs` 추가 - - VR 컨트롤러 레이로 Unity UI 버튼을 직접 hover/click 처리한다. - - `Game` 씬에서는 게임오버 전까지 비활성화하고, 메뉴 계열 씬에서는 활성화한다. -- `Assets/Script/VRPointerSetup.cs` - - `DontDestroyOnLoad` 싱글턴으로 변경되어 `Menu -> SongCreator -> Game` 같은 씬 전환 후에도 포인터를 다시 주입한다. - - `SceneManager.sceneLoaded`마다 현재 씬 컨트롤러를 검사한다. -- `Assets/VRBeatsKit/Scripts/Core/VR_InteractorController.cs` - - XR Ray Interactor enable/disable 시 `VRPointerController`도 함께 제어한다. - - 컨트롤러 구조 차이를 고려해 현재 오브젝트, 부모, 자식, 루트 하위에서 `VRPointerController`를 찾는다. -- `Assets/VRBeatsKit/Scripts/Core/AudioManager.cs` - - `AudioSource.Play()` 대신 `PlayScheduled()`를 사용하고, `AudioSettings.dspTime` 기준으로 `CurrentTime`을 계산한다. - - MP3 재생 시작 시점과 노트 스폰 기준 시간이 프레임 상태에 따라 흔들리는 문제를 줄이기 위한 변경이다. -- `Assets/VRBeatsKit/Scripts/Core/VR_BeatCube.cs` - - `IsCutIntentValid()`를 public으로 변경하고 `maxCutAngle`을 추가했다. -- `Assets/VRBeatsKit/Scripts/Core/Cuttable.cs` - - 색상/방향/속도가 틀린 큐브는 절단 시각 효과도 발생하지 않도록 막았다. -- `Assets/Scenes/Game.unity` - - `SongController`가 큐브 프리팹, `OnLevelComplete`, 카운트다운 텍스트와 연결되어 있다. - - `GameOverPopup`의 Back 버튼에 깨진 스크립트 참조가 있어 `LoadSceneButton`으로 복구했다. - - 현재 사용 중인 좌/우 세이버 루트 회전을 `X 45도`로 보정해 컨트롤러에서 너무 수직으로 서는 문제를 줄였다. -- `Assets/VRBeatsKit/Scenes/Menu.unity` - - `SongSelectManager`, `DownloadManager`, `SongDetailPanel`, `SongLibrary`가 연결되어 있다. - - `VRPointerSetup`이 `VR_Manager`에 추가되어 있다. -- `Assets/img/360.mp4`, `Assets/img/beatSaber.png` - - 메뉴/비주얼용 에셋으로 추가됨. -- `.gitignore` - - `*.csproj.user` 제외 추가. -- `.gitattributes` - - `*.mp4 binary` 추가. - -### 현재 주의사항 - -1. `Assets/StreamingAssets/nas_config.json`과 루트 `env`는 로컬 전용이다. NAS 업로드 테스트 전 `Assets/StreamingAssets/nas_config.example.json`을 `nas_config.json`으로 복사한 뒤 계정/비밀번호를 직접 넣되 절대 커밋하지 않는다. -2. Unity 콘솔의 `Unable to start Oculus XR Plugin`은 헤드셋/오큘러스 런타임 상태 문제일 수 있다. PC 에디터 단독 실행에서는 경고가 날 수 있다. -3. `The referenced script (Unknown) on this Behaviour is missing!` 경고가 남는 씬/프리팹은 추가 확인 대상이다. 게임 진행을 막는 직접 원인은 아니지만, 인스펙터에서 Missing Script를 정리하는 것이 좋다. -4. 영상 배경은 Unity 호환 MP4가 안전하다. H.264 timestamp warning이 뜨는 원본은 baseline profile, CFR, yuv420p로 재인코딩한다. -5. `Assets/img` 아래 영상 파일은 크기가 커질 수 있다. 원격 저장소 정책에 걸리면 Git LFS 전환을 검토한다. -6. 싱크 보정 화면은 UI/입력 구조까지 들어갔지만, 기기별 오디오 지연 값은 Quest 실기에서 측정해야 한다. -7. 세이버 잔상, 큐브 이동 속도, 블룸 강도는 체감 튜닝 항목이다. 현재 값은 빌드/컴파일 기준으로 안전하지만, VR 착용 테스트 후 수치를 조정하는 것이 좋다. - ---- - -## 기존 프로젝트 소스 코드 - -**기존 프로젝트 전체 파일은 아래 git 저장소에서 가져온다.** - -``` -https://whdwo798.synology.me/whdwo798/BeatSaber.git -``` - -```bash -git clone https://whdwo798.synology.me/whdwo798/BeatSaber.git -``` - -> 단, 이 저장소는 구버전 프로젝트다. 새 프로젝트는 VRBeatsKit을 기반으로 재구성하며, -> 위 저장소의 `Assets/Script/` 등 핵심 스크립트를 참고/이식하는 용도로 사용한다. - ---- - -## Git 설정 (새 프로젝트) - -새 Unity 프로젝트를 생성한 뒤 **가장 먼저** git을 초기화하고 파일을 커밋해야 한다. -Claude Code는 대화 시작 시 `git status` / `git log`를 자동으로 읽어 컨텍스트를 파악한다. -커밋이 없으면 Claude가 변경 이력을 추적할 수 없다. - -### 초기화 순서 - -```bash -# 새 프로젝트 루트에서 -git init -git remote add origin -``` - -### .gitignore - -기존 프로젝트의 `.gitignore`를 복사하면 된다. 핵심 규칙: - -```gitignore -# Unity 표준 -/Library/ -/Temp/ -/Obj/ -/Build/ -/Builds/ -/Logs/ -/UserSettings/ - -# NAS 비밀번호 — 절대 커밋 금지 -/Assets/StreamingAssets/nas_config.json -/Assets/StreamingAssets/nas_config.json.meta -``` - -### 첫 커밋 - -파일 복사 완료 후: - -```bash -git add . -git commit -m "init: VRBeatsKit 기반 프로젝트 초기 설정" -``` - -이후 기능 단위로 커밋하면 Claude가 `git log`로 작업 이력을 파악한다. - ---- - -## 새 프로젝트 구성 방법 - -### 전제 조건 - -1. Unity Hub에서 **새 URP 3D 프로젝트** 생성 (기존 프로젝트와 동일 Unity 버전) -2. Asset Store에서 **VRBeatsKit** 임포트 -3. Package Manager에서 아래 패키지 설치: - - XR Interaction Toolkit (3.x) - - XR Hands - - OpenXR Plugin - - TextMeshPro - - Unity Input System - -### 복사할 파일 (기존 프로젝트 → 새 프로젝트) - -아래 폴더/파일을 `Assets/` 아래에 그대로 복사한다. - -``` -Assets/Script/ ← 아래 "복사 제외" 목록 참고 -Assets/Editor/VRBeatSaberSceneBuilder.cs -Assets/StreamingAssets/ ← nas_config.json 포함 (절대 git 커밋 금지) -Assets/Fonts/NanumGothic SDF.asset 및 관련 파일 -Assets/360Music/ -Assets/Audio/ ← HitSound.wav, MissSound.wav -Assets/Prefab/ ← RED.prefab, BLUE.prefab (추후 VRBeatsKit 큐브로 교체) -``` - -### 복사 제외 (VRBeatsKit으로 대체) - -``` -Assets/Script/Saber.cs → VRBeatsKit VR_Saber.cs 사용 -Assets/Script/Cube.cs → VRBeatsKit VR_BeatCube.cs 사용 -``` - ---- - -## 전체 씬 구성 - -``` -Intro → SongSelect → Game -SongSelect → SongCreator → (NAS 업로드) → SongSelect -``` - -| 씬 | 역할 | -|---|---| -| Intro | 로고 → SongSelect 자동 전환 | -| SongSelect | NAS에서 songs.json 로드, 곡 목록 표시, 다운로드/플레이 | -| Game | 음악 재생 + 큐브 스폰 + 점수/HP + 결과 화면 | -| SongCreator | 음악 파일 선택 → Beat Sage API 채보 → NAS 업로드 | -| MapEditorScene | 맵 에디터 (선택적) | - ---- - -## 전체 데이터 흐름 - -``` -[SongCreator] - 사용자: 음악 파일 선택 (로컬 파일 또는 URL) - → BeatSageUploader: Beat Sage API 채보 요청 - POST https://beatsage.com/create - → GET /heartbeat/{id} 폴링 - → GET /download/{id} → .zip (Normal.dat, Hard.dat, Expert.dat, ExpertPlus.dat) - → BeatSageConverter: .dat → NoteData 변환 - → NasPublisher: Synology NAS 업로드 - songs.json 갱신: /web/beatsaber/songs.json - 맵 JSON: /web/beatsaber/maps/Map_{id}_{diff}.json - 오디오: /web/beatsaber/music/{id}.mp3 - -[SongSelect] - → DownloadManager: NAS에서 songs.json 로드 - → 사용자 곡 선택 → GameSession.SelectedSong, GameSession.SelectedDifficulty 설정 - → 다운로드: {id}.mp3 + Map_{id}_{diff}.json → Application.temporaryCachePath/beatsaber/{id}/ - -[Game] - → Spawner.InitGame(): 캐시에서 오디오/맵 로드 - → VRBeatsKit AudioManager AudioSource에 클립 세팅 - → 카운트다운 3→2→1→GO - → 매 프레임: audioSource.time 기준으로 VR_BeatManager.Spawn() 호출 - → ScoreManager: 히트/미스 집계 → HP → 결과 화면 -``` - ---- - -## 주요 스크립트 역할 - -### 복사하는 스크립트 (수정 없음) - -| 파일 | 역할 | -|---|---| -| `GameSession.cs` | static 컨테이너 — 씬 간 선택 곡/난이도 전달 | -| `NoteData.cs` | DTO — NoteData, MapData, SongInfo, DifficultyMap 등 | -| `BeatSageConverter.cs` | Beat Sage .dat 형식 → NoteData 변환 | -| `BeatSageUploader.cs` | Beat Sage API 연동 (POST/GET). `LastMetadata` 프로퍼티에 info.dat 파싱 결과 저장. | -| `NasPublisher.cs` | Synology DSM 7.2 API 업로드 | -| `DownloadManager.cs` | NAS → 로컬 캐시 다운로드 | -| `SongLibrary.cs` | 다운로드 상태 추적 (persistentDataPath) | -| `SongSelectManager.cs` | 곡 목록 UI | -| `SongDetailPanel.cs` | 곡 상세 / 다운로드 / 플레이 버튼 | -| `SongCreatorManager.cs` | 크리에이터 UI, 파일 선택, URL 다운로드. title/BPM 수동 입력 불필요 — info.dat에서 자동 추출. 난이도는 현재 항상 4개 전부 생성. | -| `SongController.cs` | Game 씬 실행부. 캐시된 mp3/map json을 로드하고 VRBeatsKit `VR_BeatManager.Spawn()`으로 노트를 스폰. | -| `DesktopUIMode.cs` | 에디터에서 XR 없이 테스트할 때 UI Raycaster 교체 | -| `VRPointerController.cs` | VR 컨트롤러 레이로 UI hover/click 처리. 디버그 로그 포함. | -| `VRPointerSetup.cs` | 씬 로드 후 손/컨트롤러 오브젝트에 `VRPointerController` 자동 주입. | -| `XRSimulatorLoader.cs` | 에디터/PC 테스트용 XR Interaction Simulator 프리팹 주입. | - -### 현재 미이식/미확인 스크립트 - -| 파일 | 내용 | -|---|---| -| `IntroManager.cs` | 현재 저장소에 없음. 인트로 씬 흐름을 살릴 경우 작성/이식 필요. | -| `ScoreManager.cs`, `ScoreHUD.cs`, `ResultsPanel.cs` | 전역 네임스페이스 커스텀 점수 UI는 현재 저장소에 없음. 현재는 VRBeatsKit `VRBeats.ScoreManager`와 이벤트 자산을 사용. | -| `SaberGlow.cs`, `SaberSkinSelector.cs`, `CacheManager.cs` | 현재 저장소에 없음. 필요 시 기존 프로젝트에서 이식. | - ---- - -## Game 실행부 현재 구현 - -기존 인수인계 문서에는 `Spawner.cs`를 새로 작성하라고 되어 있었지만, 현재 저장소에서는 별도 `Spawner.cs` 대신 `Assets/Script/SongController.cs`가 그 역할을 수행한다. - -### `SongController.cs` 핵심 - -```csharp -private IEnumerator LoadAndPlay() -{ - SongInfo song = GameSession.SelectedSong; - string diff = GameSession.SelectedDifficulty; - // mp3와 map json을 Application.temporaryCachePath/beatsaber/{songId}/ 에서 로드 - // 카운트다운 후 VRBeats.AudioManager.PlayClip(clip) - // SpawnRoutine(map.target) 실행 -} - -private void SpawnNote(NoteData note) -{ - float x = MapLaneX(note.position); - float y = MapLayerY(note.lineLayer); - - 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 = note.time - _audio.CurrentTime, - }; - - VR_BeatManager.instance.Spawn(cubePrefab, info); -} -``` - -`travelTimeOverride`는 동시 노트가 프레임 차이로 스폰되어도 같은 타이밍에 도착하도록 `VR_BeatManager`에 추가된 값이다. - -현재 라인 매핑은 `LaneSpacing = 0.42f`, `LayerSpacing = 0.38f`를 사용한다. 이는 VRBeatsKit 큐브 콜라이더의 실제 폭이 기존 라인 간격보다 커서 인접 라인이 겹치던 문제를 피하기 위한 값이다. - ---- - -## ScoreManager 충돌 없음 - -- 예전 설계: 전역 네임스페이스 커스텀 `ScoreManager.cs` -- 현재 저장소: `Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`의 `VRBeats.ScoreManager` 사용 -- 전역 커스텀 `ScoreManager.cs`를 다시 이식하면 네임스페이스가 달라 공존은 가능하지만, 이벤트 연결과 UI 패널을 별도로 구성해야 한다. - ---- - -## NAS 설정 - -| 항목 | 값 | -|---|---| -| DSM API (내부) | `http://192.168.55.3:5000` | -| DSM API (외부) | `http://whdwo798.synology.me` | -| 정적 파일 서버 | `http://whdwo798.synology.me/beatsaber` | -| NAS 루트 경로 | `/web/beatsaber` | -| 비밀번호 저장 위치 | `Assets/StreamingAssets/nas_config.json` | - -**보안 규칙**: `nas_config.json`은 절대 git에 커밋하지 않는다. `.gitignore`에 추가 필수. - -```json -{ - "host": "http://192.168.55.3:5000", - "publicHost": "http://whdwo798.synology.me", - "account": "계정명", - "password": "비밀번호" -} -``` - ---- - -## NAS 파일 구조 - -``` -/web/beatsaber/ -├── songs.json ← 전체 곡 목록 -├── maps/ -│ └── Map_{id}_{difficulty}.json ← 난이도별 맵 (NoteData 배열) -└── music/ - └── {id}.mp3 ← 오디오 파일 -``` - -### songs.json 형식 -```json -{ - "version": "1.0", - "songs": [ - { - "id": "uuid", - "title": "곡 제목", - "artist": "아티스트", - "bpm": 120.0, - "duration": 180, - "audioFile": "music/uuid.mp3", - "audioSize": 1234567, - "coverImage": "", - "noteJumpSpeed": 10.0, - "difficulties": { - "normal": { "mapFile": "maps/Map_uuid_normal.json", "mapSize": 0, "noteCount": 0 }, - "hard": { "mapFile": "maps/Map_uuid_hard.json", "mapSize": 0, "noteCount": 0 }, - "expert": { "mapFile": "maps/Map_uuid_expert.json", "mapSize": 0, "noteCount": 0 }, - "expertplus": { "mapFile": "maps/Map_uuid_expertplus.json", "mapSize": 0, "noteCount": 0 } - }, - "addedAt": "2026-05-21T00:00:00Z" - } - ] -} -``` - -### 맵 JSON 형식 (Map_{id}_{diff}.json) -```json -{ - "target": [ - { "time": 1.23, "position": 1, "lineLayer": 1, "colorType": 0, "cutDirection": 1 } - ] -} -``` - ---- - -## Beat Sage API - -- **Base URL**: `https://beatsage.com` -- **흐름**: `POST /create` → `GET /heartbeat/{id}` 폴링 (status: "DONE") → `GET /download/{id}` (.zip) -- **지원 난이도**: Normal, Hard, Expert, ExpertPlus -- **zip 내 파일명**: `Normal.dat`, `Hard.dat`, `Expert.dat`, `ExpertPlus.dat`, `info.dat` -- **인증 불필요** (퍼블릭 API) -- **입력 방식 2가지**: `audio_file`(로컬 파일 업로드) 또는 `audio_url`(직접 URL 전달, Beat Sage 서버에서 다운로드) -- **info.dat 활용**: `_beatsPerMinute`(자동 감지), `_songName`, `_songAuthorName` 추출 → `BeatSageUploader.LastMetadata`에 저장. SongCreatorManager에서 이 값을 우선 사용하고 UI 입력이 있으면 override. - ---- - -## ScoreManager 명세 - -```csharp -public class ScoreManager : MonoBehaviour -{ - public static ScoreManager Instance; - public int Score; - public int Combo; - public int MaxCombo; - public int Multiplier; // 1/2/4/8 — 4콤보마다 증가 - public int HP; // 기본 100, 미스 시 -10, 0이면 게임오버 - public const int MaxHP = 100; - public float HitRate; // notesHit / noteCount (0~1) - - public event Action OnScoreChanged; // score, combo, multiplier - public event Action OnHPChanged; - public event Action OnGameOver; - - public void SetNoteCount(int count); - public void RegisterHit(); - public void RegisterMiss(); -} -``` - -### 랭크 기준 (ResultsPanel) -| 랭크 | HitRate | -|---|---| -| S | 95% 이상 | -| A | 80% 이상 | -| B | 65% 이상 | -| C | 50% 이상 | -| D | 50% 미만 | - ---- - -## VRBeatsKit 주요 클래스 요약 - -| 클래스 | 역할 | -|---|---| -| `VR_BeatManager` | 싱글턴 — 큐브 스폰, 색상 설정, GameOver | -| `VR_BeatCube` | 큐브 이동 + 히트/미스 판정 | -| `VR_BeatCubeSpawneable` | 큐브 스폰 설정 (화살표/점, ColorSide) | -| `VR_Saber` | 세이버 슬라이싱 (EzySlice 기반) | -| `SpawnEventInfo` | 스폰 파라미터 (hitDirection, colorSide, position, speed) | -| `AudioManager` | AudioSource + AudioMixer 래퍼 | -| `VR_BeatSettings` | ScriptableObject — 색상, 속도, 멀티플라이어 한도 등 | - -### SpawnEventInfo 구조 -```csharp -public class SpawnEventInfo { - public Direction hitDirection; // UpperLeft=0,Up=1,UpperRight=2,Left=3,Center=4,Right=5,LowerLeft=6,Down=7,LowerRight=8 - public ColorSide colorSide; // Left, Right - public bool useSpark; - public Vector3 position; // -0.5~0.5 정규화 (PlayZone 기준) - public Vector3 rotation; - public float speed; - public int speedMultiplier; -} -``` - ---- - -## 로컬 캐시 경로 - -``` -Application.temporaryCachePath/beatsaber/{songId}/ -├── {songId}.mp3 -├── Map_{songId}_normal.json -├── Map_{songId}_hard.json -├── Map_{songId}_expert.json -└── Map_{songId}_expertplus.json -``` - ---- - -## 알려진 주의사항 - -1. **Game 씬 직접 Play 주의**: `GameSession.SelectedSong == null`이면 `SongController`가 오류를 로그로 남기고 진행하지 않는다. 곡 플레이는 `Menu.unity`의 SongSelect에서 선택/다운로드 후 진입해야 한다. -2. **NAS 업로드**: 수동 multipart body (UploadHandlerRaw) 사용. Unity 기본 multipart는 DSM에서 401 오류. -3. **AudioType.MPEG**: MP3 로딩 시 `UnityWebRequestMultimedia.GetAudioClip(uri, AudioType.MPEG)` 사용. -4. **Unity `??` 연산자**: Unity Object에 `??` 쓰면 fake-null을 못 잡음. 반드시 `if (x == null)` 또는 `TryGetComponent` 사용. -5. **Build Settings 현재 상태**: 현재 등록 순서는 `Menu`, `BoxingStyle`, `SongCreator`, `SaberStyle`, `Game`이다. 예전 목표 설계의 `Intro`, `SongSelect`, `MapEditorScene`은 현재 Build Settings에 없다. -6. **경고 0 상태 유지**: 패키지 내부까지 경고를 제거해 둔 상태라, 새 SDK/API를 추가할 때 `dotnet build VRBeatSaber.slnx --no-incremental`로 경고 재발 여부를 확인한다. -7. **VR 실기 테스트 필수 항목**: 게임오버 Back/Retry 클릭, SongCreator UI 클릭, 큐브 가로 간격, 큐브 도착 싱크, 세이버 각도는 Quest에서 직접 확인해야 한다. +1. Game 씬 Play 후 HUD 좌우 위치 확인 +2. 시간바가 실제 노래 시간에 따라 차오르는지 확인 +3. 노래가 중간에 조기 종료되지 않는지 확인 +4. 낮은 큐브가 베기 편해졌는지 VR에서 확인 +5. 결과 화면에서 `M/S+/S/F` 등급 배지의 크기, 위치, 색상 확인 +6. `M` 달성 조건이 의도대로 최종 100만점일 때만 뜨는지 확인 diff --git a/tools/unity-mcp-server/index.mjs b/tools/unity-mcp-server/index.mjs index 35ad2ca..3875744 100644 --- a/tools/unity-mcp-server/index.mjs +++ b/tools/unity-mcp-server/index.mjs @@ -4,7 +4,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -const UNITY_BRIDGE_URL = (process.env.UNITY_BRIDGE_URL || "http://127.0.0.1:19744").replace(/\/$/, ""); +const EXPLICIT_UNITY_BRIDGE_URL = process.env.UNITY_BRIDGE_URL; +let unityBridgeUrl = (EXPLICIT_UNITY_BRIDGE_URL || "http://127.0.0.1:19744").replace(/\/$/, ""); const server = new McpServer({ name: "vrbeats-unity", @@ -24,13 +25,7 @@ function textResult(value) { } async function callUnity(path, options = {}) { - const response = await fetch(`${UNITY_BRIDGE_URL}${path}`, { - method: options.method || "GET", - headers: { - "Content-Type": "application/json", - }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }); + const response = await fetchUnity(path, options); const rawText = await response.text(); let parsed; @@ -49,6 +44,40 @@ async function callUnity(path, options = {}) { return parsed; } +async function fetchUnity(path, options = {}) { + const urls = [unityBridgeUrl]; + + if (!EXPLICIT_UNITY_BRIDGE_URL) { + for (let port = 19744; port <= 19748; port += 1) { + const url = `http://127.0.0.1:${port}`; + if (!urls.includes(url)) { + urls.push(url); + } + } + } + + let lastError; + + for (const url of urls) { + try { + const response = await fetch(`${url}${path}`, { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + unityBridgeUrl = url; + return response; + } catch (error) { + lastError = error; + } + } + + throw lastError; +} + function queryString(params) { const query = new URLSearchParams();