feat: polish game HUD scoring and results

This commit is contained in:
2026-05-29 00:32:21 +09:00
parent c4330aa544
commit b46ccddbdb
14 changed files with 768 additions and 646 deletions
+42 -6
View File
@@ -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) +
+8 -8
View File
@@ -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
+9 -3
View File
@@ -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
@@ -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;
}
}
@@ -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);
@@ -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;
}
}
}
+7 -1
View File
@@ -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;
}
}
}
+152 -26
View File
@@ -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<ScoreManager>();
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<RectTransform>();
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<RectTransform>();
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<TextMeshProUGUI>();
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;
}
}
}
+172 -42
View File
@@ -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<CanvasGroup>() ?? gameObject.AddComponent<CanvasGroup>();
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 $"<size=150%><color=#41F2FF>{Rank}</color></size>\n" +
$"<size=118%>{score}</size>\n" +
$"<size=56%><color=#D7F7FF>ACC {AccuracyPercent:0.0}% {badge}</color></size>\n" +
$"<size=50%>MAX COMBO {maxCombo}</size>\n" +
$"<size=42%><color=#A9B7C0>P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}</color></size>";
return $"<line-height=76%><size=300%><color={GetRankColorHex()}>{Rank}</color></size>" +
$"<pos=255><voffset=0.48em><size=92%>{score}</size></voffset>\n" +
$"<pos=255><size=72%><color=#D7F7FF>MAX COMBO {maxCombo}</color></size>";
}
private void CancelTweenById(int id)
@@ -277,8 +306,8 @@ namespace VRBeats
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
if (comboLabel != null)
comboLabel.text = currentCombo > 0
? $"<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>{currentCombo}</size>"
: "<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>0</size>";
? $"<size=18><color=#E6F8FF>COMBO</color></size>\n<size=42>{currentCombo}</size>"
: "<size=18><color=#E6F8FF>COMBO</color></size>\n<size=42>0</size>";
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<RectTransform>();
textObject.AddComponent<CanvasRenderer>();
Text text = textObject.AddComponent<Text>();
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<CanvasRenderer>();
return imageObject.AddComponent<Image>();
}
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<Font>("LegacyRuntime.ttf");
return hudFont;
}
}
private Text FindHudText(string objectName)
{
Transform child = transform.Find(objectName);
return child != null ? child.GetComponent<Text>() : null;
}
private Image FindHudImage(string objectName)
{
Transform child = transform.Find(objectName);
return child != null ? child.GetComponent<Image>() : 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<VR_Saber>(FindObjectsSortMode.None);
for (int i = 0; i < sabers.Length; i++)
{
if (sabers[i] == null)
continue;
if (visible)
sabers[i].MakeVisible();
else
sabers[i].MakeInvisible();
}
}
}
}
+1 -1
View File
@@ -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
+90
View File
@@ -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.
+20
View File
@@ -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.
+189 -539
View File
@@ -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 <GitHub 저장소 URL>
```
### .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<int, int, int> OnScoreChanged; // score, combo, multiplier
public event Action<int> 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만점일 때만 뜨는지 확인
+37 -8
View File
@@ -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();