feat: polish game HUD scoring and results
This commit is contained in:
@@ -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) +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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만점일 때만 뜨는지 확인
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user