Compare commits

7 Commits

Author SHA1 Message Date
jongjae0305 c335995a9a feat: update song selection, score UI, and song creator features
- SongSelectManager/SongDetailPanel: 곡 선택 및 상세 패널 개선
- SongCreatorManager: 곡 생성 기능 추가
- FinalScoreLabel/ScoreManager: 결과 화면 점수 UI 업데이트
- MarqueeText: 마퀴 텍스트 컴포넌트 개선
- NoteData/SongController: 노트 데이터 및 컨트롤러 보완

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:29:50 +09:00
whdwo798 72dad1ce4c docs: update project handoff 2026-05-29 00:53:49 +09:00
whdwo798 fb59fc36f7 feat: polish result screen UI 2026-05-29 00:51:32 +09:00
whdwo798 b46ccddbdb feat: polish game HUD scoring and results 2026-05-29 00:32:21 +09:00
jongjae0305 c4330aa544 docs: add NAS config template 2026-05-28 19:03:34 +09:00
jongjae0305 03105a4f85 feat: polish VR gameplay and sync tools 2026-05-28 19:01:20 +09:00
jongjae0305 ee34d79a66 docs: update html code review docs 2026-05-26 19:12:06 +09:00
63 changed files with 6660 additions and 998 deletions
+16
View File
@@ -20,5 +20,21 @@
# Credentials — never commit
.env
/env
/cookies.txt
/Assets/StreamingAssets/nas_config.json
/Assets/StreamingAssets/nas_config.json.meta
# Local tool output
/Captures/
/tools/unity-mcp-server/node_modules/
/Assets/_Recovery/
/Assets/_Recovery.meta
# Local video sources / superseded test clips
/Assets/img/*.mkv
/Assets/img/*.mkv.meta
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4.meta
/Assets/img/neon_background_unity.mp4
/Assets/img/neon_background_unity.mp4.meta
+946
View File
@@ -0,0 +1,946 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace VRBeats.EditorTools
{
[InitializeOnLoad]
internal static class UnityCodexBridgeServer
{
private const int PreferredPort = 19744;
private const int MaxPortAttempts = 5;
private const string AutoStartPrefKey = "VRBeats.CodexBridge.AutoStart";
private const int MaxLogs = 250;
private static readonly ConcurrentQueue<BridgeJob> Jobs = new ConcurrentQueue<BridgeJob>();
private static readonly List<LogEntry> Logs = new List<LogEntry>();
private static readonly object LogLock = new object();
private static TcpListener _listener;
private static Thread _serverThread;
private static bool _running;
private static int _port = PreferredPort;
private static int _logIndex;
static UnityCodexBridgeServer()
{
if (IsBackgroundEditorProcess())
return;
Application.logMessageReceived -= OnLogMessageReceived;
Application.logMessageReceived += OnLogMessageReceived;
EditorApplication.update -= ProcessJobs;
EditorApplication.update += ProcessJobs;
EditorApplication.quitting -= StopServer;
EditorApplication.quitting += StopServer;
AssemblyReloadEvents.beforeAssemblyReload -= StopServer;
AssemblyReloadEvents.beforeAssemblyReload += StopServer;
if (EditorPrefs.GetBool(AutoStartPrefKey, true))
StartServer();
}
[MenuItem("Tools/Codex Bridge/Start Server")]
private static void StartServerMenu()
{
StartServer();
}
[MenuItem("Tools/Codex Bridge/Stop Server")]
private static void StopServerMenu()
{
StopServer();
}
[MenuItem("Tools/Codex Bridge/Auto Start")]
private static void ToggleAutoStart()
{
bool enabled = !EditorPrefs.GetBool(AutoStartPrefKey, true);
EditorPrefs.SetBool(AutoStartPrefKey, enabled);
if (enabled)
StartServer();
}
[MenuItem("Tools/Codex Bridge/Auto Start", true)]
private static bool ValidateToggleAutoStart()
{
Menu.SetChecked("Tools/Codex Bridge/Auto Start", EditorPrefs.GetBool(AutoStartPrefKey, true));
return true;
}
[MenuItem("Tools/Codex Bridge/Capture Game View")]
private static void CaptureGameViewMenu()
{
BridgeResponse response = CaptureGameView(new Dictionary<string, string>());
Debug.Log("[CodexBridge] Capture result: " + response.Body);
}
[MenuItem("Tools/Codex Bridge/Open Sync Calibration")]
private static void OpenSyncCalibrationMenu()
{
SyncCalibrationOverlay.Open();
}
private static void StartServer()
{
if (IsBackgroundEditorProcess())
return;
if (_running)
return;
try
{
_listener = CreateListener();
_running = true;
_serverThread = new Thread(ServerLoop)
{
IsBackground = true,
Name = "UnityCodexBridgeServer"
};
_serverThread.Start();
Debug.Log("[CodexBridge] Listening on http://127.0.0.1:" + _port);
}
catch (Exception ex)
{
_running = false;
try
{
if (_listener != null)
_listener.Stop();
}
catch
{
// Ignore cleanup failures after a failed bind.
}
finally
{
_listener = null;
}
Debug.LogWarning("[CodexBridge] Failed to start: " + ex.Message);
}
}
private static void StopServer()
{
_running = false;
try
{
if (_listener != null)
_listener.Stop();
}
catch
{
// Ignore shutdown races.
}
_listener = null;
if (_serverThread != null && _serverThread.IsAlive)
_serverThread.Join(200);
_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;
return Application.isBatchMode ||
commandLine.IndexOf("AssetImportWorker", StringComparison.OrdinalIgnoreCase) >= 0 ||
commandLine.IndexOf("-batchMode", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static void ServerLoop()
{
while (_running)
{
try
{
TcpClient client = _listener.AcceptTcpClient();
ThreadPool.QueueUserWorkItem(_ => HandleClient(client));
}
catch
{
if (_running)
Thread.Sleep(100);
}
}
}
private static void HandleClient(TcpClient client)
{
using (client)
{
try
{
client.ReceiveTimeout = 5000;
client.SendTimeout = 5000;
BridgeRequest request = ReadRequest(client.GetStream());
BridgeResponse response;
if (request == null)
{
response = BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"invalid_request\"}");
}
else if (request.Method == "OPTIONS")
{
response = BridgeResponse.Json(204, string.Empty);
}
else
{
BridgeJob job = new BridgeJob(request);
Jobs.Enqueue(job);
if (!job.Done.Wait(TimeSpan.FromSeconds(10)))
response = BridgeResponse.Json(504, "{\"ok\":false,\"error\":\"unity_main_thread_timeout\"}");
else
response = job.Response;
}
WriteResponse(client.GetStream(), response);
}
catch (Exception ex)
{
try
{
WriteResponse(client.GetStream(),
BridgeResponse.Json(500, "{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}"));
}
catch
{
// Client has already gone away.
}
}
}
}
private static BridgeRequest ReadRequest(Stream stream)
{
StreamReader reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true);
string requestLine = reader.ReadLine();
if (string.IsNullOrEmpty(requestLine))
return null;
string[] requestParts = requestLine.Split(' ');
if (requestParts.Length < 2)
return null;
int contentLength = 0;
string line;
while (!string.IsNullOrEmpty(line = reader.ReadLine()))
{
int separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
continue;
string headerName = line.Substring(0, separatorIndex).Trim();
string headerValue = line.Substring(separatorIndex + 1).Trim();
if (string.Equals(headerName, "Content-Length", StringComparison.OrdinalIgnoreCase))
int.TryParse(headerValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out contentLength);
}
string body = string.Empty;
if (contentLength > 0)
{
char[] buffer = new char[contentLength];
int read = reader.ReadBlock(buffer, 0, contentLength);
body = new string(buffer, 0, read);
}
Uri uri = new Uri("http://127.0.0.1" + requestParts[1]);
return new BridgeRequest(requestParts[0].ToUpperInvariant(), uri.AbsolutePath, ParseQuery(uri.Query), body);
}
private static Dictionary<string, string> ParseQuery(string query)
{
Dictionary<string, string> values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(query))
return values;
string trimmed = query[0] == '?' ? query.Substring(1) : query;
string[] pairs = trimmed.Split('&');
foreach (string pair in pairs)
{
if (string.IsNullOrEmpty(pair))
continue;
int separatorIndex = pair.IndexOf('=');
string key = separatorIndex >= 0 ? pair.Substring(0, separatorIndex) : pair;
string value = separatorIndex >= 0 ? pair.Substring(separatorIndex + 1) : string.Empty;
values[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(value.Replace("+", " "));
}
return values;
}
private static void WriteResponse(Stream stream, BridgeResponse response)
{
if (response == null)
response = BridgeResponse.Json(500, "{\"ok\":false,\"error\":\"null_response\"}");
byte[] body = Encoding.UTF8.GetBytes(response.Body ?? string.Empty);
string headers =
"HTTP/1.1 " + response.StatusCode + " " + StatusText(response.StatusCode) + "\r\n" +
"Content-Type: " + response.ContentType + "; charset=utf-8\r\n" +
"Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n" +
"Access-Control-Allow-Origin: http://localhost\r\n" +
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" +
"Access-Control-Allow-Headers: Content-Type\r\n" +
"Connection: close\r\n\r\n";
byte[] headerBytes = Encoding.UTF8.GetBytes(headers);
stream.Write(headerBytes, 0, headerBytes.Length);
if (body.Length > 0)
stream.Write(body, 0, body.Length);
}
private static string StatusText(int statusCode)
{
switch (statusCode)
{
case 200: return "OK";
case 204: return "No Content";
case 400: return "Bad Request";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 500: return "Internal Server Error";
case 504: return "Gateway Timeout";
default: return "OK";
}
}
private static void ProcessJobs()
{
while (Jobs.TryDequeue(out BridgeJob job))
{
try
{
job.Response = Execute(job.Request);
}
catch (Exception ex)
{
job.Response = BridgeResponse.Json(500,
"{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}");
}
finally
{
job.Done.Set();
}
}
}
private static BridgeResponse Execute(BridgeRequest request)
{
switch (request.Path)
{
case "/health":
case "/state":
return GetHealth();
case "/capture":
return CaptureGameView(request.Query);
case "/logs":
return GetLogs(request.Query);
case "/scene/roots":
return GetSceneRoots();
case "/scene/objects":
return GetSceneObjects(request.Query);
case "/object":
return GetObjectDetails(request.Query);
case "/play":
return SetPlayState(true, false);
case "/pause":
return SetPlayState(true, true);
case "/stop":
return SetPlayState(false, false);
case "/sync/open":
SyncCalibrationOverlay.Open();
return BridgeResponse.Json(200, "{\"ok\":true,\"opened\":\"sync_calibration\"}");
case "/transform":
if (request.Method != "POST")
return BridgeResponse.Json(405, "{\"ok\":false,\"error\":\"method_not_allowed\"}");
return SetTransform(request.Body);
default:
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"not_found\"}");
}
}
private static BridgeResponse GetHealth()
{
Scene scene = SceneManager.GetActiveScene();
string body =
"{\"ok\":true" +
",\"bridge\":\"unity-codex-bridge\"" +
",\"port\":" + _port.ToString(CultureInfo.InvariantCulture) +
",\"unityVersion\":" + JsonString(Application.unityVersion) +
",\"projectPath\":" + JsonString(Directory.GetCurrentDirectory()) +
",\"scene\":" + JsonString(scene.IsValid() ? scene.name : string.Empty) +
",\"isPlaying\":" + Bool(EditorApplication.isPlaying) +
",\"isPaused\":" + Bool(EditorApplication.isPaused) +
"}";
return BridgeResponse.Json(200, body);
}
private static BridgeResponse CaptureGameView(Dictionary<string, string> query)
{
int width = GetInt(query, "width", 1280, 320, 4096);
int height = GetInt(query, "height", 720, 180, 4096);
string relativePath = "Captures/latest.png";
string absolutePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
Directory.CreateDirectory(Path.GetDirectoryName(absolutePath));
Camera camera = ResolveCaptureCamera();
string cameraName = camera != null ? camera.name : string.Empty;
bool usedCameraRender = false;
if (camera != null)
{
RenderTexture previousTarget = camera.targetTexture;
RenderTexture previousActive = RenderTexture.active;
RenderTexture renderTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32);
Texture2D texture = null;
try
{
camera.targetTexture = renderTexture;
RenderTexture.active = renderTexture;
camera.Render();
texture = new Texture2D(width, height, TextureFormat.RGB24, false);
texture.ReadPixels(new Rect(0, 0, width, height), 0, 0);
texture.Apply();
File.WriteAllBytes(absolutePath, texture.EncodeToPNG());
usedCameraRender = true;
}
finally
{
camera.targetTexture = previousTarget;
RenderTexture.active = previousActive;
RenderTexture.ReleaseTemporary(renderTexture);
if (texture != null)
UnityEngine.Object.DestroyImmediate(texture);
}
}
if (!usedCameraRender)
ScreenCapture.CaptureScreenshot(relativePath);
string body =
"{\"ok\":true" +
",\"path\":" + JsonString(absolutePath) +
",\"relativePath\":" + JsonString(relativePath) +
",\"width\":" + width.ToString(CultureInfo.InvariantCulture) +
",\"height\":" + height.ToString(CultureInfo.InvariantCulture) +
",\"camera\":" + JsonString(cameraName) +
",\"mode\":" + JsonString(usedCameraRender ? "camera_render" : "screen_capture") +
"}";
return BridgeResponse.Json(200, body);
}
private static Camera ResolveCaptureCamera()
{
Camera main = Camera.main;
if (main != null && main.isActiveAndEnabled)
return main;
Camera[] cameras = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
foreach (Camera camera in cameras)
{
if (camera != null && camera.isActiveAndEnabled)
return camera;
}
return main != null ? main : (cameras.Length > 0 ? cameras[0] : null);
}
private static BridgeResponse GetLogs(Dictionary<string, string> query)
{
int count = GetInt(query, "count", 80, 1, MaxLogs);
List<LogEntry> snapshot;
lock (LogLock)
{
int start = Mathf.Max(0, Logs.Count - count);
snapshot = Logs.GetRange(start, Logs.Count - start);
}
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"logs\":[");
for (int i = 0; i < snapshot.Count; i++)
{
if (i > 0)
sb.Append(',');
LogEntry entry = snapshot[i];
sb.Append('{');
sb.Append("\"index\":").Append(entry.Index.ToString(CultureInfo.InvariantCulture)).Append(',');
sb.Append("\"time\":").Append(JsonString(entry.Time)).Append(',');
sb.Append("\"type\":").Append(JsonString(entry.Type)).Append(',');
sb.Append("\"message\":").Append(JsonString(entry.Message)).Append(',');
sb.Append("\"stackTrace\":").Append(JsonString(entry.StackTrace));
sb.Append('}');
}
sb.Append("]}");
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetSceneRoots()
{
Scene scene = SceneManager.GetActiveScene();
GameObject[] roots = scene.IsValid() ? scene.GetRootGameObjects() : Array.Empty<GameObject>();
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"scene\":").Append(JsonString(scene.IsValid() ? scene.name : string.Empty));
sb.Append(",\"roots\":[");
for (int i = 0; i < roots.Length; i++)
{
if (i > 0)
sb.Append(',');
AppendGameObjectSummary(sb, roots[i]);
}
sb.Append("]}");
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetSceneObjects(Dictionary<string, string> query)
{
string filter = GetString(query, "query", string.Empty);
int limit = GetInt(query, "limit", 120, 1, 500);
string lowerFilter = filter.ToLowerInvariant();
Transform[] transforms = UnityEngine.Object.FindObjectsByType<Transform>(FindObjectsInactive.Include, FindObjectsSortMode.None);
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"objects\":[");
int written = 0;
foreach (Transform transform in transforms)
{
if (transform == null || transform.gameObject == null)
continue;
if (EditorUtility.IsPersistent(transform.gameObject))
continue;
if (!transform.gameObject.scene.IsValid())
continue;
string path = GetHierarchyPath(transform.gameObject);
if (!string.IsNullOrEmpty(lowerFilter) &&
path.ToLowerInvariant().IndexOf(lowerFilter, StringComparison.Ordinal) < 0)
continue;
if (written > 0)
sb.Append(',');
AppendGameObjectSummary(sb, transform.gameObject);
written++;
if (written >= limit)
break;
}
sb.Append("],\"count\":").Append(written.ToString(CultureInfo.InvariantCulture)).Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetObjectDetails(Dictionary<string, string> query)
{
string path = GetString(query, "path", string.Empty);
GameObject go = FindGameObjectByPath(path);
if (go == null)
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}");
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"object\":");
AppendGameObjectDetails(sb, go);
sb.Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse SetPlayState(bool play, bool pause)
{
EditorApplication.isPlaying = play;
EditorApplication.isPaused = pause;
return GetHealth();
}
private static BridgeResponse SetTransform(string body)
{
string path = ExtractString(body, "path");
if (string.IsNullOrEmpty(path))
return BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"missing_path\"}");
GameObject go = FindGameObjectByPath(path);
if (go == null)
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}");
Undo.RecordObject(go.transform, "Codex Bridge Set Transform");
if (TryExtractVector3(body, "position", out Vector3 position))
go.transform.position = position;
if (TryExtractVector3(body, "localPosition", out Vector3 localPosition))
go.transform.localPosition = localPosition;
if (TryExtractVector3(body, "rotationEuler", out Vector3 rotationEuler))
go.transform.eulerAngles = rotationEuler;
if (TryExtractVector3(body, "localRotationEuler", out Vector3 localRotationEuler))
go.transform.localEulerAngles = localRotationEuler;
if (TryExtractVector3(body, "scale", out Vector3 scale))
go.transform.localScale = scale;
EditorUtility.SetDirty(go.transform);
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"object\":");
AppendGameObjectDetails(sb, go);
sb.Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static void AppendGameObjectSummary(StringBuilder sb, GameObject go)
{
sb.Append('{');
sb.Append("\"name\":").Append(JsonString(go.name)).Append(',');
sb.Append("\"path\":").Append(JsonString(GetHierarchyPath(go))).Append(',');
sb.Append("\"activeSelf\":").Append(Bool(go.activeSelf)).Append(',');
sb.Append("\"activeInHierarchy\":").Append(Bool(go.activeInHierarchy)).Append(',');
sb.Append("\"scene\":").Append(JsonString(go.scene.IsValid() ? go.scene.name : string.Empty)).Append(',');
sb.Append("\"position\":").Append(VectorJson(go.transform.position)).Append(',');
sb.Append("\"rotationEuler\":").Append(VectorJson(go.transform.eulerAngles)).Append(',');
sb.Append("\"scale\":").Append(VectorJson(go.transform.localScale));
sb.Append('}');
}
private static void AppendGameObjectDetails(StringBuilder sb, GameObject go)
{
AppendGameObjectSummary(sb, go);
sb.Length -= 1;
Component[] components = go.GetComponents<Component>();
sb.Append(",\"components\":[");
for (int i = 0; i < components.Length; i++)
{
if (i > 0)
sb.Append(',');
Component component = components[i];
sb.Append(JsonString(component != null ? component.GetType().FullName : "Missing Script"));
}
sb.Append("]}");
}
private static GameObject FindGameObjectByPath(string path)
{
if (string.IsNullOrEmpty(path))
return null;
string[] parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return null;
Scene scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
return null;
foreach (GameObject root in scene.GetRootGameObjects())
{
if (root.name != parts[0])
continue;
Transform current = root.transform;
for (int i = 1; i < parts.Length && current != null; i++)
{
current = FindChildByName(current, parts[i]);
}
if (current != null)
return current.gameObject;
}
return null;
}
private static Transform FindChildByName(Transform parent, string childName)
{
for (int i = 0; i < parent.childCount; i++)
{
Transform child = parent.GetChild(i);
if (child.name == childName)
return child;
}
return null;
}
private static string GetHierarchyPath(GameObject go)
{
Stack<string> parts = new Stack<string>();
Transform current = go.transform;
while (current != null)
{
parts.Push(current.name);
current = current.parent;
}
return string.Join("/", parts.ToArray());
}
private static void OnLogMessageReceived(string condition, string stackTrace, LogType type)
{
lock (LogLock)
{
Logs.Add(new LogEntry
{
Index = ++_logIndex,
Time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
Type = type.ToString(),
Message = condition ?? string.Empty,
StackTrace = stackTrace ?? string.Empty
});
if (Logs.Count > MaxLogs)
Logs.RemoveAt(0);
}
}
private static int GetInt(Dictionary<string, string> query, string key, int fallback, int min, int max)
{
if (!query.TryGetValue(key, out string value) ||
!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
return fallback;
return Mathf.Clamp(parsed, min, max);
}
private static string GetString(Dictionary<string, string> query, string key, string fallback)
{
return query.TryGetValue(key, out string value) ? value : fallback;
}
private static string ExtractString(string json, string key)
{
if (string.IsNullOrEmpty(json))
return string.Empty;
Match match = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"",
RegexOptions.CultureInvariant);
return match.Success ? Regex.Unescape(match.Groups[1].Value) : string.Empty;
}
private static bool TryExtractVector3(string json, string key, out Vector3 value)
{
value = Vector3.zero;
if (string.IsNullOrEmpty(json))
return false;
Match objectMatch = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*\\{(?<body>.*?)\\}",
RegexOptions.Singleline | RegexOptions.CultureInvariant);
if (!objectMatch.Success)
return false;
string objectBody = objectMatch.Groups["body"].Value;
if (!TryExtractFloat(objectBody, "x", out float x) ||
!TryExtractFloat(objectBody, "y", out float y) ||
!TryExtractFloat(objectBody, "z", out float z))
return false;
value = new Vector3(x, y, z);
return true;
}
private static bool TryExtractFloat(string json, string key, out float value)
{
value = 0f;
Match match = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*(?<num>-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)",
RegexOptions.CultureInvariant);
return match.Success &&
float.TryParse(match.Groups["num"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
private static string VectorJson(Vector3 value)
{
return "{\"x\":" + FloatJson(value.x) +
",\"y\":" + FloatJson(value.y) +
",\"z\":" + FloatJson(value.z) + "}";
}
private static string FloatJson(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value))
return "0";
return value.ToString("0.#####", CultureInfo.InvariantCulture);
}
private static string Bool(bool value)
{
return value ? "true" : "false";
}
private static string JsonString(string value)
{
if (value == null)
return "null";
StringBuilder sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (char c in value)
{
switch (c)
{
case '\\':
sb.Append("\\\\");
break;
case '"':
sb.Append("\\\"");
break;
case '\n':
sb.Append("\\n");
break;
case '\r':
sb.Append("\\r");
break;
case '\t':
sb.Append("\\t");
break;
default:
if (c < 32)
sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
else
sb.Append(c);
break;
}
}
sb.Append('"');
return sb.ToString();
}
private sealed class BridgeRequest
{
public readonly string Method;
public readonly string Path;
public readonly Dictionary<string, string> Query;
public readonly string Body;
public BridgeRequest(string method, string path, Dictionary<string, string> query, string body)
{
Method = method;
Path = path;
Query = query;
Body = body ?? string.Empty;
}
}
private sealed class BridgeResponse
{
public readonly int StatusCode;
public readonly string ContentType;
public readonly string Body;
private BridgeResponse(int statusCode, string contentType, string body)
{
StatusCode = statusCode;
ContentType = contentType;
Body = body;
}
public static BridgeResponse Json(int statusCode, string body)
{
return new BridgeResponse(statusCode, "application/json", body);
}
}
private sealed class BridgeJob
{
public readonly BridgeRequest Request;
public readonly ManualResetEventSlim Done = new ManualResetEventSlim(false);
public BridgeResponse Response;
public BridgeJob(BridgeRequest request)
{
Request = request;
}
}
private sealed class LogEntry
{
public int Index;
public string Time;
public string Type;
public string Message;
public string StackTrace;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3aaf23c3d6f42b5b1f68100b9b0f682
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+108 -42
View File
@@ -225,7 +225,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.01673192, g: 0.29524884, b: 0.4433962, a: 0.98039216}
m_Color: {r: 0.01673192, g: 0.29524884, b: 0.4433962, a: 0.82}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -393,9 +393,9 @@ MonoBehaviour:
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_HighlightedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_PressedColor: {r: 0.55, g: 0.78, b: 0.9, a: 1}
m_SelectedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
@@ -428,7 +428,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Color: {r: 0.06, g: 0.12, b: 0.22, a: 0.72}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -587,13 +587,18 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: colorSide
@@ -627,12 +632,12 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.9239
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: 0.3827
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -647,7 +652,7 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 45
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -1252,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}
@@ -1263,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: -7.5, y: -1.7}
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
@@ -1284,6 +1289,15 @@ MonoBehaviour:
scoreFollowTime: 1
canvasGroup: {fileID: 454873732}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
comboLabel: {fileID: 0}
accuracyLabel: {fileID: 0}
judgementLabel: {fileID: 0}
createMissingHudLabels: 1
applyHudPlacement: 0
hudAnchoredPosition: {x: 0, y: 2.4}
perfectWindow: 0.11
greatWindow: 0.2
goodWindow: 0.32
--- !u!114 &454873731
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -2108,9 +2122,9 @@ MonoBehaviour:
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_HighlightedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_PressedColor: {r: 0.55, g: 0.78, b: 0.9, a: 1}
m_SelectedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
@@ -2143,7 +2157,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Color: {r: 0.06, g: 0.12, b: 0.22, a: 0.72}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2380,7 +2394,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_destroyOnLoad: 1
currentSDK: 0
currentSDK: 3
gestureConfig:
minAcelerationThreshold: 15
maxAcelerationThreshold: 40
@@ -2652,8 +2666,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: -1.4000015, y: 2.5099983}
m_SizeDelta: {x: 20.150002, y: 6.41}
m_AnchoredPosition: {x: 0, y: -0.45}
m_SizeDelta: {x: 18.8, y: 13.2}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1209841520
GameObject:
@@ -2692,7 +2706,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -16.119999, y: -15.380001}
m_AnchoredPosition: {x: -17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1209841522
@@ -2717,9 +2731,9 @@ MonoBehaviour:
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_HighlightedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_PressedColor: {r: 0.55, g: 0.78, b: 0.9, a: 1}
m_SelectedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
@@ -2752,7 +2766,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Color: {r: 0.06, g: 0.12, b: 0.22, a: 0.72}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2825,7 +2839,6 @@ MonoBehaviour:
playZone: {fileID: 778115775}
player: {fileID: 408071456}
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
--- !u!4 &1215359035
Transform:
m_ObjectHideFlags: 0
@@ -3790,8 +3803,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 7.1
m_fontSizeBase: 7.1
m_fontSize: 4.4
m_fontSizeBase: 4.4
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -3803,7 +3816,7 @@ MonoBehaviour:
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacing: -18
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
@@ -4109,7 +4122,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.01673192, g: 0.29524884, b: 0.4433962, a: 0.98039216}
m_Color: {r: 0.01673192, g: 0.29524884, b: 0.4433962, a: 0.82}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -4376,6 +4389,7 @@ GameObject:
m_Component:
- component: {fileID: 1958381893}
- component: {fileID: 1958381892}
- component: {fileID: 1958381894}
m_Layer: 0
m_Name: SongController
m_TagString: Untagged
@@ -4414,6 +4428,23 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1958381894
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1958381891}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3e381cd99de84f67b9f83c19a032dc24, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Game360VideoBackground
videoClip: {fileID: 32900000, guid: f47e26c36f77476ba803b5158d1b30da, type: 3}
renderTextureSize: 2048
muteVideoAudio: 1
skyboxRotationDegrees: 90
skyboxExposure: 1
--- !u!4 &2043306906 stripped
Transform:
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
@@ -4457,7 +4488,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 15.980001, y: -15.380001}
m_AnchoredPosition: {x: 17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2094521063
@@ -4482,9 +4513,9 @@ MonoBehaviour:
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_HighlightedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_PressedColor: {r: 0.55, g: 0.78, b: 0.9, a: 1}
m_SelectedColor: {r: 0.72, g: 0.92, b: 1, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
@@ -4517,7 +4548,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Color: {r: 0.06, g: 0.12, b: 0.22, a: 0.72}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -4857,6 +4888,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -4865,13 +4901,23 @@ PrefabInstance:
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: m_RootOrder
@@ -5157,6 +5203,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -5165,13 +5216,23 @@ PrefabInstance:
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: m_Name
@@ -5277,7 +5338,7 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -5289,6 +5350,11 @@ PrefabInstance:
propertyPath: startOnRightcController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: controller
@@ -5317,27 +5383,27 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.99958926
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: -0
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.y
value: -0.028659718
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.z
value: -0
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
+17 -17
View File
@@ -412,7 +412,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 12.051, y: -28.918}
m_AnchoredPosition: {x: 12.051, y: -30.052994}
m_SizeDelta: {x: 130, y: 8}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &108468832
@@ -725,7 +725,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.00000033474, y: -40.969}
m_AnchoredPosition: {x: 0.00000033474, y: -44.1}
m_SizeDelta: {x: 168, y: 0.5}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &306375770
@@ -920,7 +920,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -61.975, y: -36.665}
m_AnchoredPosition: {x: -61.975, y: -37.8}
m_SizeDelta: {x: 18, y: 7}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &354046698
@@ -1670,7 +1670,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -13.772, y: -5.6772}
m_AnchoredPosition: {x: -13.772, y: -5.2}
m_SizeDelta: {x: 120, y: 9}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &528106932
@@ -2131,7 +2131,7 @@ Transform:
m_GameObject: {fileID: 633731941}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalPosition: {x: -0.5, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2704,7 +2704,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -61.975, y: -28.918}
m_AnchoredPosition: {x: -61.975, y: -30.052994}
m_SizeDelta: {x: 18, y: 7}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &697160355
@@ -3187,7 +3187,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 24.101, y: 8.095}
m_AnchoredPosition: {x: -33.1, y: 8.095}
m_SizeDelta: {x: 88, y: 9}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &885348079
@@ -3324,7 +3324,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -69}
m_AnchoredPosition: {x: 0, y: -66}
m_SizeDelta: {x: 170, y: 10}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &927688130
@@ -3361,7 +3361,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -61.975, y: -21.171}
m_AnchoredPosition: {x: -61.975, y: -22.305994}
m_SizeDelta: {x: 18, y: 7}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &927688132
@@ -3637,7 +3637,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 12.051, y: -21.171}
m_AnchoredPosition: {x: 12.051, y: -22.305994}
m_SizeDelta: {x: 130, y: 8}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &961817605
@@ -5126,7 +5126,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -20, y: -50.2}
m_AnchoredPosition: {x: -20, y: -51.5}
m_SizeDelta: {x: 98, y: 10}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1328124617
@@ -5382,7 +5382,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.00000033474, y: -9.9814}
m_AnchoredPosition: {x: 0.00000033474, y: -11.4}
m_SizeDelta: {x: 168, y: 0.5}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1401498817
@@ -5953,7 +5953,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 65, y: -50.200005}
m_AnchoredPosition: {x: 65, y: -51.500004}
m_SizeDelta: {x: 36, y: 10}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1614055316
@@ -7070,7 +7070,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -41.317, y: 8.095}
m_AnchoredPosition: {x: 51.9, y: 8.095}
m_SizeDelta: {x: 44, y: 9}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1795570113
@@ -7191,7 +7191,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 12.051, y: -36.665}
m_AnchoredPosition: {x: 12.051, y: -37.799995}
m_SizeDelta: {x: 130, y: 8}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1799455459
@@ -7866,7 +7866,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -57.671, y: -14.285}
m_AnchoredPosition: {x: -57.671, y: -15.419995}
m_SizeDelta: {x: 30, y: 6}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1924042092
@@ -8107,7 +8107,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
nasBaseUrl: 'http://whdwo798.synology.me:5000 '
nasBaseUrl: http://whdwo798.synology.me:5000
nasAccount: beatSaber_app
nasRootPath: /web/beatsaber
staticBaseUrl: http://whdwo798.synology.me/beatsaber
+3
View File
@@ -36,6 +36,8 @@ public class BeatSageNote
public static class BeatSageConverter
{
private static readonly bool LogConversions = false;
public static List<NoteData> Convert(string rawJson, float bpm)
{
var result = new List<NoteData>();
@@ -62,6 +64,7 @@ public static class BeatSageConverter
});
}
if (LogConversions)
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
return result;
}
+53 -2
View File
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
string legacyDir = LegacySongDir(songId);
if (Directory.Exists(legacyDir))
Directory.Delete(legacyDir, recursive: true);
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
string songDir = SongDir(song.id);
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
Directory.Delete(songDir);
}
public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId));
{
TryMigrateLegacySong(songId);
return File.Exists(AudioPath(songId));
}
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
return path != null && File.Exists(path);
}
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError)
{
TryMigrateLegacySong(song.id);
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
private static string LegacySongDir(string songId)
=> Path.Combine(LegacyCacheRoot, songId);
private static void TryMigrateLegacySong(string songId)
{
string sourceDir = LegacySongDir(songId);
string targetDir = SongDir(songId);
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
return;
CopyDirectory(sourceDir, targetDir);
Directory.Delete(sourceDir, recursive: true);
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
}
private static void CopyDirectory(string sourceDir, string targetDir)
{
Directory.CreateDirectory(targetDir);
foreach (string file in Directory.GetFiles(sourceDir))
{
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
File.Copy(file, targetFile, overwrite: true);
}
foreach (string dir in Directory.GetDirectories(sourceDir))
{
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
CopyDirectory(dir, targetSubDir);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using UnityEngine;
using UnityEngine.Video;
public class Game360VideoBackground : MonoBehaviour
{
[SerializeField] private VideoClip videoClip;
[SerializeField] private int renderTextureSize = 2048;
[SerializeField] private bool muteVideoAudio = true;
[SerializeField, Range(0f, 360f)] private float skyboxRotationDegrees = 0f;
[SerializeField, Range(0f, 8f)] private float skyboxExposure = 1f;
private GameObject videoPlayerObject;
private Material skyboxMaterial;
private Material previousSkybox;
private RenderTexture renderTexture;
private VideoPlayer videoPlayer;
private void Awake()
{
if (videoClip == null)
{
Debug.LogWarning("[Game360VideoBackground] videoClip is not assigned.");
return;
}
CreateSkyboxMaterial();
CreateVideoPlayer();
}
private void OnDestroy()
{
if (videoPlayer != null)
{
videoPlayer.prepareCompleted -= OnVideoPrepared;
videoPlayer.errorReceived -= OnVideoError;
}
if (renderTexture != null)
{
renderTexture.Release();
Destroy(renderTexture);
}
RenderSettings.skybox = previousSkybox;
DynamicGI.UpdateEnvironment();
if (skyboxMaterial != null)
Destroy(skyboxMaterial);
if (videoPlayerObject != null)
Destroy(videoPlayerObject);
}
private void CreateSkyboxMaterial()
{
renderTexture = new RenderTexture(renderTextureSize, renderTextureSize / 2, 0, RenderTextureFormat.ARGB32)
{
name = "Game360VideoRenderTexture",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
};
renderTexture.Create();
previousSkybox = RenderSettings.skybox;
skyboxMaterial = new Material(ResolveSkyboxShader())
{
name = "Game360VideoMaterial",
};
skyboxMaterial.SetTexture("_MainTex", renderTexture);
skyboxMaterial.SetFloat("_ImageType", 0f);
skyboxMaterial.SetFloat("_Mapping", 0f);
skyboxMaterial.SetFloat("_Layout", 0f);
ApplySkyboxSettings();
RenderSettings.skybox = skyboxMaterial;
DynamicGI.UpdateEnvironment();
}
private void CreateVideoPlayer()
{
videoPlayerObject = new GameObject("[360 Video Skybox Player]");
videoPlayerObject.transform.SetParent(transform, false);
videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
videoPlayer.playOnAwake = false;
videoPlayer.isLooping = true;
videoPlayer.waitForFirstFrame = true;
videoPlayer.renderMode = VideoRenderMode.RenderTexture;
videoPlayer.targetTexture = renderTexture;
videoPlayer.clip = videoClip;
videoPlayer.audioOutputMode = muteVideoAudio
? VideoAudioOutputMode.None
: VideoAudioOutputMode.Direct;
videoPlayer.prepareCompleted += OnVideoPrepared;
videoPlayer.errorReceived += OnVideoError;
videoPlayer.Prepare();
}
private static Shader ResolveSkyboxShader()
{
return Shader.Find("Skybox/Panoramic")
?? Shader.Find("Skybox/6 Sided")
?? Shader.Find("Standard");
}
private void OnVideoPrepared(VideoPlayer source)
{
source.Play();
}
private void OnValidate()
{
ApplySkyboxSettings();
}
private void ApplySkyboxSettings()
{
if (skyboxMaterial == null)
return;
skyboxMaterial.SetFloat("_Exposure", skyboxExposure);
skyboxMaterial.SetFloat("_Rotation", skyboxRotationDegrees);
DynamicGI.UpdateEnvironment();
}
private static void OnVideoError(VideoPlayer source, string message)
{
Debug.LogWarning($"[Game360VideoBackground] VideoPlayer error: {message}");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e381cd99de84f67b9f83c19a032dc24
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
public static class GlobalSyncSettings
{
private const string AudioOffsetMsKey = "VRBeats.GlobalAudioOffsetMs";
public static float AudioOffsetMs
{
get => PlayerPrefs.GetFloat(AudioOffsetMsKey, 0.0f);
set
{
PlayerPrefs.SetFloat(AudioOffsetMsKey, Mathf.Clamp(value, -300.0f, 300.0f));
PlayerPrefs.Save();
}
}
public static float AudioOffsetSeconds => AudioOffsetMs / 1000.0f;
public static void Reset()
{
PlayerPrefs.DeleteKey(AudioOffsetMsKey);
PlayerPrefs.Save();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2e8c518ec2f4a03a6d820774b475ce0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+30 -5
View File
@@ -5,12 +5,13 @@ using UnityEngine;
[RequireComponent(typeof(TMP_Text))]
public class MarqueeText : MonoBehaviour
{
public float speed = 35f;
public float pauseStart = 1.5f;
public float pauseEnd = 0.6f;
public float speed = 14f;
public float pauseStart = 1.8f;
public float pauseEnd = 0.9f;
private TMP_Text _label;
private RectTransform _rect;
private Coroutine _scrollRoutine;
private void Awake()
{
@@ -20,7 +21,22 @@ public class MarqueeText : MonoBehaviour
private IEnumerator Start()
{
yield return null; // layout 완료 후 실행
yield return null;
Refresh();
}
private void OnDisable()
{
StopScrolling();
}
public void Refresh()
{
if (!isActiveAndEnabled || _label == null || _rect == null || transform.parent == null)
return;
StopScrolling();
SetX(0f);
_label.ForceMeshUpdate();
float textW = _label.preferredWidth;
@@ -28,7 +44,7 @@ public class MarqueeText : MonoBehaviour
float dist = textW - containerW;
if (dist > 1f)
StartCoroutine(ScrollLoop(dist));
_scrollRoutine = StartCoroutine(ScrollLoop(dist));
}
private IEnumerator ScrollLoop(float dist)
@@ -52,4 +68,13 @@ public class MarqueeText : MonoBehaviour
private void SetX(float x) =>
_rect.anchoredPosition = new Vector2(x, _rect.anchoredPosition.y);
private void StopScrolling()
{
if (_scrollRoutine == null)
return;
StopCoroutine(_scrollRoutine);
_scrollRoutine = null;
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class MenuSyncButtonInjector : MonoBehaviour
{
private const string MenuSceneName = "Menu";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoCreate()
{
if (FindFirstObjectByType<MenuSyncButtonInjector>() != null)
return;
GameObject go = new GameObject("[MenuSyncButtonInjector]");
DontDestroyOnLoad(go);
go.AddComponent<MenuSyncButtonInjector>();
}
private void Awake()
{
SceneManager.sceneLoaded += OnSceneLoaded;
StartCoroutine(InjectAfterFrame());
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
StartCoroutine(InjectAfterFrame());
}
private IEnumerator InjectAfterFrame()
{
yield return null;
if (SceneManager.GetActiveScene().name != MenuSceneName)
yield break;
if (GameObject.Find("SyncButton") != null)
yield break;
Button sourceButton = FindButtonByText("CreateSong");
if (sourceButton == null)
sourceButton = FindButtonByText("음악만들기");
if (sourceButton == null)
yield break;
Button syncButton = Instantiate(sourceButton, sourceButton.transform.parent);
syncButton.gameObject.name = "SyncButton";
foreach (VRBeats.LoadSceneButton loader in syncButton.GetComponents<VRBeats.LoadSceneButton>())
{
loader.enabled = false;
Destroy(loader);
}
syncButton.onClick.RemoveAllListeners();
syncButton.onClick.AddListener(SyncCalibrationOverlay.Open);
RectTransform sourceRect = sourceButton.GetComponent<RectTransform>();
RectTransform syncRect = syncButton.GetComponent<RectTransform>();
syncRect.anchoredPosition = sourceRect.anchoredPosition + new Vector2(0.0f, -22.0f);
TextMeshProUGUI tmp = syncButton.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null)
tmp.text = "SYNC";
Text text = syncButton.GetComponentInChildren<Text>(true);
if (text != null)
text.text = "SYNC";
}
private static Button FindButtonByText(string text)
{
foreach (Button button in FindObjectsByType<Button>(FindObjectsSortMode.None))
{
TextMeshProUGUI tmp = button.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null && tmp.text.Trim() == text)
return button;
Text legacyText = button.GetComponentInChildren<Text>(true);
if (legacyText != null && legacyText.text.Trim() == text)
return button;
}
return null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: efc5a20a7b4749bfb60d95ac0f0b2180
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+87 -14
View File
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Networking;
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
private void LoadConfig()
{
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
if (!File.Exists(path))
{
NormalizeSettings();
return;
}
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
if (cfg == null) return;
_password = cfg.password ?? "";
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
NormalizeSettings();
}
[Serializable] private class NasConfig
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
Action onComplete,
Action<string> onError)
{
NormalizeSettings();
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
@@ -92,13 +101,32 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError)
{
if (string.IsNullOrWhiteSpace(_password))
{
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
yield break;
}
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url);
UnityWebRequest req;
try
{
req = UnityWebRequest.Get(url);
}
catch (UriFormatException e)
{
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
yield break;
}
string resp;
using (req)
{
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
@@ -107,16 +135,19 @@ public class NasPublisher : MonoBehaviour
yield break;
}
string resp = req.downloadHandler.text;
resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
}
if (string.IsNullOrEmpty(_sid))
onError?.Invoke("DSM sid parse failed — check credentials.");
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
}
private IEnumerator Logout()
{
NormalizeSettings();
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url);
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError)
{
NormalizeSettings();
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{
NormalizeSettings();
SongsList list = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
return null;
Match match = Regex.Match(
json,
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
return match.Success
? Regex.Unescape(match.Groups["value"].Value)
: null;
}
private static void AssignMapFile(SongInfo song, string diff, string fileName)
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
var info = song.difficulties.Get(diff);
if (info != null) info.mapFile = $"maps/{fileName}";
}
private void OnValidate()
{
NormalizeSettings();
}
private void NormalizeSettings()
{
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
nasAccount = nasAccount?.Trim() ?? "";
nasRootPath = NormalizeRootPath(nasRootPath);
}
private static string NormalizeBaseUrl(string value)
{
return (value ?? "").Trim().TrimEnd('/');
}
private static string NormalizeRootPath(string value)
{
value = (value ?? "").Trim().Replace('\\', '/');
if (string.IsNullOrEmpty(value))
return "/";
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
}
private static string Shorten(string value, int maxLength = 240)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value ?? "";
return value.Substring(0, maxLength) + "...";
}
}
+13
View File
@@ -16,6 +16,19 @@ public class NoteData
public class MapData
{
public List<NoteData> target;
public ForcedResultData forcedResult;
}
[Serializable]
public class ForcedResultData
{
public bool enabled;
public int totalNotes;
public int perfect;
public int great;
public int good;
public int miss;
public int maxCombo;
}
[Serializable]
+59 -10
View File
@@ -14,21 +14,31 @@ 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;
private float _clipLength;
private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber");
Path.Combine(Application.persistentDataPath, "beatsaber");
private void Start()
{
_audio = FindFirstObjectByType<AudioManager>();
_scoreManager = FindFirstObjectByType<ScoreManager>();
StartCoroutine(LoadAndPlay());
}
private void Update()
{
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
}
private IEnumerator LoadAndPlay()
{
SongInfo song = GameSession.SelectedSong;
@@ -53,6 +63,7 @@ public class SongController : MonoBehaviour
}
clip = DownloadHandlerAudioClip.GetContent(req);
}
_clipLength = clip.length;
// Load and parse map
DifficultyInfo diffInfo = song.difficulties.Get(diff);
@@ -68,19 +79,49 @@ public class SongController : MonoBehaviour
yield break;
}
MapData map = JsonUtility.FromJson<MapData>(File.ReadAllText(mapPath));
if (map?.target == null)
if (map == null)
{
Debug.LogError("[SongController] Map parse failed");
yield break;
}
if (map.target == null)
map.target = new List<NoteData>();
if (IsForcedResultMap(map))
{
_scoreManager?.SetTotalNotes(Mathf.Max(0, map.forcedResult.totalNotes));
yield return StartCoroutine(Countdown());
_audio.PlayClip(clip);
yield return new WaitForSeconds(Mathf.Min(Mathf.Max(0.2f, _clipLength), 0.75f));
_scoreManager?.ApplyForcedResult(
map.forcedResult.totalNotes,
map.forcedResult.perfect,
map.forcedResult.great,
map.forcedResult.good,
map.forcedResult.miss,
map.forcedResult.maxCombo);
_scoreManager?.CompleteSong();
onLevelComplete?.Invoke();
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());
_audio.PlayClip(clip);
StartCoroutine(SpawnRoutine(map.target));
yield return StartCoroutine(WaitForCompletion(clip.length));
yield return StartCoroutine(WaitForCompletion(_clipLength, map.target));
}
private IEnumerator Countdown()
@@ -106,7 +147,8 @@ public class SongController : MonoBehaviour
foreach (NoteData note in notes)
{
float spawnAt = Mathf.Max(0f, note.time - travelTime);
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
SpawnNote(note);
}
@@ -118,7 +160,7 @@ public class SongController : MonoBehaviour
float y = MapLayerY(note.lineLayer);
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
float remaining = note.time - _audio.CurrentTime;
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
float travelTime = Mathf.Max(0.05f, remaining);
var info = new SpawnEventInfo
@@ -126,7 +168,7 @@ public class SongController : MonoBehaviour
position = new Vector3(x, y, 0f),
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
hitDirection = MapCutDirection(note.cutDirection),
useSpark = true,
useSpark = false,
speed = 2f,
travelTimeOverride = travelTime,
};
@@ -147,6 +189,9 @@ public class SongController : MonoBehaviour
return a.lineLayer.CompareTo(b.lineLayer);
}
private static bool IsForcedResultMap(MapData map)
=> map?.forcedResult != null && map.forcedResult.enabled;
private static float MapLaneX(int position)
{
int lane = Mathf.Clamp(position, 0, 3);
@@ -156,7 +201,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
@@ -177,9 +222,13 @@ public class SongController : MonoBehaviour
private static Direction MapCutDirection(int cut)
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
private IEnumerator WaitForCompletion(float clipLength)
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
{
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
yield return new WaitForSeconds(0.35f);
_scoreManager?.CompleteSong();
onLevelComplete?.Invoke();
}
}
+63
View File
@@ -50,14 +50,27 @@ public class SongCreatorManager : MonoBehaviour
[SerializeField] private BeatSageUploader beatSageUploader;
[SerializeField] private NasPublisher nasPublisher;
private static readonly Color NeonBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
private static readonly Color DarkButtonBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
private static readonly Color DisabledBg = new Color(0.06f, 0.12f, 0.15f, 0.48f);
private static readonly Color ButtonText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
private static readonly Color MutedText = new Color(0.66f, 0.80f, 0.84f, 0.76f);
private static readonly Color NeonOutline = new Color(0.25f, 0.96f, 1.0f, 0.42f);
private static string InputPath =>
Path.Combine(Application.persistentDataPath, "input");
private readonly List<string> audioFiles = new();
private string _pendingFilePath;
private void OnValidate()
{
ApplyButtonStyles();
}
private void Start()
{
ApplyButtonStyles();
Directory.CreateDirectory(InputPath);
if (inputPathHint != null)
@@ -258,6 +271,7 @@ public class SongCreatorManager : MonoBehaviour
if (refreshBtn != null) refreshBtn.interactable = value;
if (filePickerBtn != null) filePickerBtn.interactable = value;
if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
ApplyButtonStyles();
}
private void OnFilePickerClicked()
@@ -323,6 +337,7 @@ public class SongCreatorManager : MonoBehaviour
{
SetAddStatus("Downloading...");
if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;
ApplyButtonStyles();
string fileName;
try
@@ -341,6 +356,7 @@ public class SongCreatorManager : MonoBehaviour
yield return req.SendWebRequest();
if (urlDownloadBtn != null) urlDownloadBtn.interactable = true;
ApplyButtonStyles();
if (req.result == UnityWebRequest.Result.Success)
{
@@ -358,4 +374,51 @@ public class SongCreatorManager : MonoBehaviour
}
private void SetAddStatus(string msg) { if (addStatusText != null) addStatusText.text = msg; }
private void ApplyButtonStyles()
{
ApplyCreatorButtonStyle(generateButton, true);
ApplyCreatorButtonStyle(urlDownloadBtn, true);
ApplyCreatorButtonStyle(refreshBtn, false);
ApplyCreatorButtonStyle(filePickerBtn, false);
ApplyCreatorButtonStyle(backButton, false);
}
private static void ApplyCreatorButtonStyle(Button btn, bool primary)
{
if (btn == null)
return;
Color bg = btn.interactable ? (primary ? NeonBg : DarkButtonBg) : DisabledBg;
if (btn.targetGraphic is Image img)
{
img.color = bg;
img.raycastTarget = true;
}
var colors = btn.colors;
colors.normalColor = bg;
colors.highlightedColor = btn.interactable
? new Color(0.10f, 0.95f, 1.0f, primary ? 0.58f : 0.48f)
: DisabledBg;
colors.pressedColor = btn.interactable
? new Color(0.02f, 0.58f, 0.72f, 0.80f)
: DisabledBg;
colors.selectedColor = colors.highlightedColor;
colors.disabledColor = DisabledBg;
colors.fadeDuration = 0.08f;
btn.colors = colors;
TMP_Text label = btn.GetComponentInChildren<TMP_Text>(true);
if (label != null)
{
label.color = btn.interactable ? ButtonText : MutedText;
label.raycastTarget = false;
}
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
outline.enabled = btn.interactable;
outline.effectColor = NeonOutline;
outline.effectDistance = new Vector2(0.0f, -0.28f);
}
}
+158 -11
View File
@@ -33,13 +33,20 @@ public class SongDetailPanel : MonoBehaviour
[Header("씬 이름")]
[SerializeField] private string gameSceneName = "Game";
private static readonly Color SelectedColor = new Color(0.2f, 0.78f, 0.4f);
private static readonly Color DeselectedImgColor = new Color(1f, 1f, 1f, 0.12f); // original button alpha
private static readonly Color NeonBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
private static readonly Color DarkButtonBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
private static readonly Color DisabledBg = new Color(0.06f, 0.12f, 0.15f, 0.48f);
private static readonly Color DangerBg = new Color(0.52f, 0.16f, 0.22f, 0.72f);
private static readonly Color ButtonText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
private static readonly Color MutedText = new Color(0.66f, 0.80f, 0.84f, 0.76f);
private static readonly Color NeonOutline = new Color(0.25f, 0.96f, 1.0f, 0.42f);
private SongInfo currentSong;
private string selectedDifficulty;
private DownloadManager downloadManager;
private SongSelectManager selectManager;
private MarqueeText titleMarquee;
private MarqueeText artistMarquee;
private readonly (string key, Func<SongDetailPanel, Button> btn)[] diffSlots =
{
@@ -49,6 +56,22 @@ public class SongDetailPanel : MonoBehaviour
("expertplus", p => p.btnExpertPlus),
};
private void Awake()
{
HideDifficultyLabel();
titleMarquee = ConfigureMarqueeText(titleText, 5.0f, 7.2f);
artistMarquee = ConfigureMarqueeText(artistText, 3.4f, 4.4f);
ConfigureOneLineText(infoText, 3.2f, 4.2f, TextAlignmentOptions.MidlineLeft);
ConfigureButtonText(btnNormal, 3.2f, 4.0f);
ConfigureButtonText(btnHard, 3.2f, 4.0f);
ConfigureButtonText(btnExpert, 3.2f, 4.0f);
ConfigureButtonText(btnExpertPlus, 3.0f, 3.8f);
ConfigureButtonText(downloadButton, 3.5f, 4.4f);
ConfigureButtonText(deleteButton, 3.5f, 4.4f);
ConfigureButtonText(playButton, 3.5f, 4.4f);
ConfigureButtonText(closeButton, 5.2f, 6.4f);
}
// ── Public API ───────────────────────────────────────────
public void Show(SongInfo song, DownloadManager dm, SongSelectManager sm)
@@ -61,9 +84,11 @@ public class SongDetailPanel : MonoBehaviour
titleText.text = song.title;
artistText.text = song.artist;
infoText.text = song.duration > 0
? $"BPM {Mathf.RoundToInt(song.bpm)} | {FormatDuration(song.duration)}"
? $"BPM {Mathf.RoundToInt(song.bpm)} {FormatDuration(song.duration)}"
: $"BPM {Mathf.RoundToInt(song.bpm)}";
titleMarquee?.Refresh();
artistMarquee?.Refresh();
RefreshUI();
}
@@ -92,8 +117,11 @@ public class SongDetailPanel : MonoBehaviour
downloadButton.gameObject.SetActive(!downloaded);
deleteButton.gameObject.SetActive(downloaded);
downloadButton.interactable = !downloaded;
deleteButton.interactable = downloaded;
playButton.interactable = downloaded && selectedDifficulty != null;
progressGroup.SetActive(false);
UpdateActionButtonStyles(downloaded);
downloadButton.onClick.RemoveAllListeners();
downloadButton.onClick.AddListener(OnDownloadClicked);
@@ -116,6 +144,7 @@ public class SongDetailPanel : MonoBehaviour
selectedDifficulty = difficulty;
playButton.interactable = true;
UpdateDiffColors();
UpdateActionButtonStyles(true);
}
private void UpdateDiffColors()
@@ -125,14 +154,7 @@ public class SongDetailPanel : MonoBehaviour
Button btn = getBtn(this);
bool selected = key == selectedDifficulty;
if (btn.targetGraphic is Image img)
img.color = selected ? SelectedColor : DeselectedImgColor;
var cb = btn.colors;
cb.normalColor = Color.white;
cb.highlightedColor = selected ? new Color(0.3f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.25f);
cb.pressedColor = selected ? new Color(0.15f, 0.6f, 0.3f) : new Color(1f, 1f, 1f, 0.35f);
btn.colors = cb;
ApplyButtonStyle(btn, selected ? NeonBg : DarkButtonBg, selected, btn.interactable, false);
}
}
@@ -234,4 +256,129 @@ public class SongDetailPanel : MonoBehaviour
private static string FormatDuration(int seconds)
=> $"{seconds / 60}:{seconds % 60:D2}";
private void HideDifficultyLabel()
{
Transform label = transform.Find("LblDifficulty");
if (label != null)
label.gameObject.SetActive(false);
}
private void UpdateActionButtonStyles(bool downloaded)
{
ApplyButtonStyle(downloadButton, NeonBg, true, !downloaded, false);
ApplyButtonStyle(deleteButton, DangerBg, true, downloaded, true);
ApplyButtonStyle(playButton, NeonBg, true, playButton.interactable, false);
ApplyButtonStyle(closeButton, DarkButtonBg, false, true, false);
}
private static void ApplyButtonStyle(Button btn, Color activeBg, bool outlined, bool enabled, bool danger)
{
if (btn == null)
return;
Color bg = enabled ? activeBg : DisabledBg;
if (btn.targetGraphic is Image img)
img.color = bg;
var colors = btn.colors;
colors.normalColor = bg;
colors.highlightedColor = enabled
? (danger ? new Color(0.72f, 0.23f, 0.30f, 0.86f) : new Color(0.10f, 0.95f, 1.0f, 0.58f))
: DisabledBg;
colors.pressedColor = enabled
? (danger ? new Color(0.42f, 0.10f, 0.15f, 0.92f) : new Color(0.02f, 0.58f, 0.72f, 0.80f))
: DisabledBg;
colors.selectedColor = colors.highlightedColor;
colors.disabledColor = DisabledBg;
colors.fadeDuration = 0.08f;
btn.colors = colors;
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
if (label != null)
label.color = enabled ? ButtonText : MutedText;
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
outline.enabled = outlined && enabled;
outline.effectColor = danger ? new Color(1.0f, 0.35f, 0.42f, 0.34f) : NeonOutline;
outline.effectDistance = new Vector2(0.0f, -0.28f);
}
private static MarqueeText ConfigureMarqueeText(TMP_Text text, float minSize, float maxSize)
{
if (text == null)
return null;
RectTransform textRect = text.rectTransform;
Transform originalParent = textRect.parent;
int siblingIndex = textRect.GetSiblingIndex();
string maskName = $"{text.name}Mask";
Transform existingMask = originalParent != null ? originalParent.Find(maskName) : null;
RectTransform maskRect;
if (existingMask != null)
{
maskRect = existingMask as RectTransform;
if (textRect.parent != existingMask)
textRect.SetParent(existingMask, false);
}
else
{
var mask = new GameObject(maskName);
mask.transform.SetParent(originalParent, false);
mask.transform.SetSiblingIndex(siblingIndex);
maskRect = mask.AddComponent<RectTransform>();
maskRect.anchorMin = textRect.anchorMin;
maskRect.anchorMax = textRect.anchorMax;
maskRect.pivot = textRect.pivot;
maskRect.anchoredPosition = textRect.anchoredPosition;
maskRect.sizeDelta = textRect.sizeDelta;
maskRect.localRotation = textRect.localRotation;
maskRect.localScale = textRect.localScale;
mask.AddComponent<RectMask2D>();
textRect.SetParent(mask.transform, false);
}
textRect.anchorMin = new Vector2(0f, 0f);
textRect.anchorMax = new Vector2(0f, 1f);
textRect.pivot = new Vector2(0f, 0.5f);
textRect.anchoredPosition = Vector2.zero;
textRect.localRotation = Quaternion.identity;
textRect.localScale = Vector3.one;
textRect.sizeDelta = new Vector2(260.0f, 0f);
ConfigureOneLineText(text, minSize, maxSize, TextAlignmentOptions.MidlineLeft);
text.overflowMode = TextOverflowModes.Overflow;
text.raycastTarget = false;
MarqueeText marquee = text.GetComponent<MarqueeText>() ?? text.gameObject.AddComponent<MarqueeText>();
marquee.speed = 9f;
marquee.pauseStart = 1.25f;
marquee.pauseEnd = 0.8f;
return marquee;
}
private static void ConfigureButtonText(Button btn, float minSize, float maxSize)
{
if (btn == null)
return;
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
ConfigureOneLineText(label, minSize, maxSize, TextAlignmentOptions.Center);
if (label != null)
label.raycastTarget = false;
}
private static void ConfigureOneLineText(TMP_Text text, float minSize, float maxSize, TextAlignmentOptions alignment)
{
if (text == null)
return;
text.enableAutoSizing = true;
text.fontSizeMin = minSize;
text.fontSizeMax = maxSize;
text.alignment = alignment;
text.overflowMode = TextOverflowModes.Ellipsis;
text.textWrappingMode = TextWrappingModes.NoWrap;
}
}
+136 -36
View File
@@ -16,8 +16,11 @@ public class SongSelectManager : MonoBehaviour
[SerializeField] private TMP_Text errorText;
private static readonly Color TabActive = new Color(1f, 1f, 1f, 0.45f);
private static readonly Color TabInactive = new Color(1f, 1f, 1f, 0.12f);
private static readonly Color TabActiveBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
private static readonly Color TabInactiveBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
private static readonly Color TabActiveText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
private static readonly Color TabInactiveText = new Color(0.72f, 0.86f, 0.90f, 0.82f);
private static readonly Color TabActiveOutline = new Color(0.25f, 0.96f, 1.0f, 0.55f);
private static string CachePath =>
Path.Combine(Application.persistentDataPath, "songs_cache.json");
@@ -49,17 +52,39 @@ public class SongSelectManager : MonoBehaviour
private void SetTabVisual(bool owned)
{
ApplyTabColor(tabAllBtn, owned ? TabInactive : TabActive);
ApplyTabColor(tabOwnedBtn, owned ? TabActive : TabInactive);
ApplyTabStyle(tabAllBtn, !owned);
ApplyTabStyle(tabOwnedBtn, owned);
}
private static void ApplyTabColor(Button btn, Color c)
private static void ApplyTabStyle(Button btn, bool active)
{
if (btn == null)
return;
Color bg = active ? TabActiveBg : TabInactiveBg;
if (btn.targetGraphic is Image img)
img.color = c;
img.color = bg;
var colors = btn.colors;
colors.normalColor = Color.white;
colors.normalColor = bg;
colors.highlightedColor = active
? new Color(0.10f, 0.95f, 1.0f, 0.58f)
: new Color(0.14f, 0.34f, 0.40f, 0.72f);
colors.pressedColor = active
? new Color(0.02f, 0.58f, 0.72f, 0.72f)
: new Color(0.08f, 0.20f, 0.24f, 0.82f);
colors.selectedColor = colors.highlightedColor;
colors.disabledColor = new Color(0.05f, 0.10f, 0.12f, 0.45f);
btn.colors = colors;
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
if (label != null)
label.color = active ? TabActiveText : TabInactiveText;
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
outline.enabled = active;
outline.effectColor = TabActiveOutline;
outline.effectDistance = new Vector2(0.0f, -0.35f);
}
private void FetchSongs()
@@ -70,9 +95,10 @@ public class SongSelectManager : MonoBehaviour
downloadManager.FetchSongsList(
onSuccess: list =>
{
allSongs = list.songs;
SaveCache(list);
SongLibrary.Instance.ValidateWithFileSystem(downloadManager, list.songs);
allSongs = list.songs ?? new List<SongInfo>();
AddLocalForcedRankDummies(allSongs);
SaveCache(new SongsList { version = list.version, songs = allSongs });
SongLibrary.Instance.ValidateWithFileSystem(downloadManager, allSongs);
loadingOverlay.SetActive(false);
RefreshCards();
},
@@ -134,14 +160,16 @@ public class SongSelectManager : MonoBehaviour
bc.fadeDuration = 0.1f;
btn.colors = bc;
float textLeftInset = downloaded ? 12f : 5f;
// Title — RectMask2D 컨테이너 안에서 마퀴 스크롤
var titleMask = new GameObject("TitleMask");
titleMask.transform.SetParent(card.transform, false);
var tmr = titleMask.AddComponent<RectTransform>();
tmr.anchorMin = new Vector2(0f, 0.5f);
tmr.anchorMax = new Vector2(1f, 1f);
tmr.offsetMin = new Vector2(5f, 0f);
tmr.offsetMax = new Vector2(downloaded ? -20f : -3f, 0f);
tmr.offsetMin = new Vector2(textLeftInset, 0f);
tmr.offsetMax = new Vector2(-3f, 0f);
titleMask.AddComponent<RectMask2D>();
var titleGO = new GameObject("Title");
@@ -166,41 +194,37 @@ public class SongSelectManager : MonoBehaviour
var artistGO = new GameObject("Artist");
artistGO.transform.SetParent(card.transform, false);
var ar = artistGO.AddComponent<RectTransform>();
ar.anchorMin = new Vector2(0f, 0f);
ar.anchorMax = new Vector2(1f, 0.5f);
ar.offsetMin = new Vector2(5f, 1f);
ar.anchorMin = new Vector2(0f, 0.04f);
ar.anchorMax = new Vector2(1f, 0.48f);
ar.offsetMin = new Vector2(textLeftInset, 0f);
ar.offsetMax = new Vector2(-3f, 0f);
var aTmp = artistGO.AddComponent<TextMeshProUGUI>();
if (_cardFont != null) aTmp.font = _cardFont;
aTmp.text = song.artist;
aTmp.fontSize = 4f;
aTmp.enableAutoSizing = true;
aTmp.fontSizeMin = 2.8f;
aTmp.fontSizeMax = 4f;
aTmp.color = new Color(1f, 1f, 1f, 0.6f);
aTmp.alignment = TextAlignmentOptions.MidlineLeft;
aTmp.overflowMode = TextOverflowModes.Ellipsis;
aTmp.textWrappingMode = TextWrappingModes.NoWrap;
// Downloaded badge
// Downloaded check mark
if (downloaded)
{
var badge = new GameObject("Badge");
badge.transform.SetParent(card.transform, false);
var br = badge.AddComponent<RectTransform>();
br.anchorMin = new Vector2(1f, 0.5f);
br.anchorMax = new Vector2(1f, 0.5f);
br.pivot = new Vector2(1f, 0.5f);
br.anchoredPosition = new Vector2(-3f, 0f);
br.sizeDelta = new Vector2(14f, 5.5f);
badge.AddComponent<Image>().color = new Color(0.2f, 0.78f, 0.4f, 0.85f);
var checkGO = new GameObject("OwnedCheck");
checkGO.transform.SetParent(card.transform, false);
var cr = checkGO.AddComponent<RectTransform>();
cr.anchorMin = new Vector2(0f, 0f);
cr.anchorMax = new Vector2(0f, 1f);
cr.pivot = new Vector2(0f, 0.5f);
cr.anchoredPosition = new Vector2(3.0f, 0f);
cr.sizeDelta = new Vector2(6f, 0f);
var bl = new GameObject("Text");
bl.transform.SetParent(badge.transform, false);
var blr = bl.AddComponent<RectTransform>();
blr.anchorMin = Vector2.zero;
blr.anchorMax = Vector2.one;
blr.offsetMin = blr.offsetMax = Vector2.zero;
var blTmp = bl.AddComponent<TextMeshProUGUI>();
blTmp.text = "OWNED";
blTmp.fontSize = 3.5f;
blTmp.color = Color.white;
blTmp.alignment = TextAlignmentOptions.Center;
Color checkColor = new Color(0.36f, 1.0f, 0.58f, 0.95f);
CreateCheckStroke(checkGO.transform, "ShortStroke", new Vector2(1.8f, 7.1f), new Vector2(1.5f, 0.35f), 42.0f, checkColor);
CreateCheckStroke(checkGO.transform, "LongStroke", new Vector2(3.25f, 7.85f), new Vector2(3.7f, 0.35f), -45.0f, checkColor);
}
SongInfo captured = song;
@@ -213,6 +237,25 @@ public class SongSelectManager : MonoBehaviour
detailPanel.Show(song, downloadManager, this);
}
private static void CreateCheckStroke(Transform parent, string name, Vector2 anchoredPosition,
Vector2 size, float rotationZ, Color color)
{
var stroke = new GameObject(name);
stroke.transform.SetParent(parent, false);
var rect = stroke.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0f, 0f);
rect.anchorMax = new Vector2(0f, 0f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
rect.localRotation = Quaternion.Euler(0f, 0f, rotationZ);
var img = stroke.AddComponent<Image>();
img.color = color;
img.raycastTarget = false;
}
private static void SaveCache(SongsList list)
{
try { File.WriteAllText(CachePath, JsonUtility.ToJson(list, true)); }
@@ -225,4 +268,61 @@ public class SongSelectManager : MonoBehaviour
try { return JsonUtility.FromJson<SongsList>(File.ReadAllText(CachePath)); }
catch { return null; }
}
private static void AddLocalForcedRankDummies(List<SongInfo> songs)
{
string root = Path.Combine(Application.persistentDataPath, "beatsaber");
AddLocalForcedRankDummy(songs, root, "dummy_rank_m", "M", 10);
AddLocalForcedRankDummy(songs, root, "dummy_rank_splus", "S+", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_s", "S", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_a", "A", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_b", "B", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_c", "C", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_d", "D", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_f", "F", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_splus_border", "S+ 98%", 100);
AddLocalForcedRankDummy(songs, root, "dummy_rank_f_zero", "F 0%", 20);
}
private static void AddLocalForcedRankDummy(List<SongInfo> songs, string root, string id, string title, int noteCount)
{
if (songs.Exists(song => song.id == id))
return;
string songDir = Path.Combine(root, id);
string audioPath = Path.Combine(songDir, $"{id}.mp3");
string mapFile = $"Map_{id}_forced.json";
string mapPath = Path.Combine(songDir, mapFile);
if (!File.Exists(audioPath) || !File.Exists(mapPath))
return;
long audioSize = new FileInfo(audioPath).Length;
long mapSize = new FileInfo(mapPath).Length;
DifficultyInfo info = new DifficultyInfo
{
mapFile = mapFile,
mapSize = mapSize,
noteCount = noteCount
};
songs.Insert(0, new SongInfo
{
id = id,
title = title,
artist = "Forced Rank Dummy",
bpm = 120.0f,
duration = 1,
audioFile = $"dummy/{id}.mp3",
audioSize = audioSize,
coverImage = "",
difficulties = new DifficultyMap
{
normal = info,
hard = info,
expert = info,
expertplus = info
},
addedAt = "2026-05-29"
});
}
}
+341
View File
@@ -0,0 +1,341 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.XR;
using XRCommonUsages = UnityEngine.XR.CommonUsages;
using XRInputDevice = UnityEngine.XR.InputDevice;
public class SyncCalibrationOverlay : MonoBehaviour
{
private const float TickInterval = 1.0f;
private const int MaxSamples = 8;
private static Scene pendingReturnScene;
private readonly List<float> samples = new();
private readonly List<(Canvas canvas, bool enabled)> hiddenCanvases = new();
private AudioSource audioSource = null;
private AudioClip tickClip = null;
private RectTransform sweepDot = null;
private RectTransform visualPulse = null;
private TextMeshProUGUI offsetText = null;
private TextMeshProUGUI sampleText = null;
private TextMeshProUGUI guideText = null;
private float lastTickTime = 0.0f;
private float nextTickTime = 0.0f;
private float pendingVisualPulseTime = -1.0f;
private float visualPulseTimer = 0.0f;
private bool previousPrimary = false;
private bool previousSecondary = false;
private Scene returnScene;
private Scene syncScene;
private bool canvasesRestored = false;
public static void Open()
{
if (FindFirstObjectByType<SyncCalibrationOverlay>() != null)
return;
pendingReturnScene = SceneManager.GetActiveScene();
Scene calibrationScene = SceneManager.CreateScene("SyncCalibration");
SceneManager.SetActiveScene(calibrationScene);
GameObject overlay = new GameObject("[SyncCalibrationOverlay]");
overlay.AddComponent<SyncCalibrationOverlay>();
}
private void Awake()
{
syncScene = gameObject.scene;
returnScene = pendingReturnScene;
HideExistingCanvases();
BuildView();
tickClip = CreateTickClip();
audioSource = gameObject.AddComponent<AudioSource>();
audioSource.playOnAwake = false;
audioSource.spatialBlend = 0.0f;
float now = Time.unscaledTime;
lastTickTime = now;
nextTickTime = now + 0.8f;
UpdateTexts();
}
private void OnDestroy()
{
RestoreCanvases();
}
private void Update()
{
float now = Time.unscaledTime;
if (now >= nextTickTime)
{
lastTickTime = nextTickTime;
nextTickTime += TickInterval;
audioSource.PlayOneShot(tickClip);
pendingVisualPulseTime = lastTickTime + GlobalSyncSettings.AudioOffsetSeconds;
}
if (pendingVisualPulseTime > 0.0f && now >= pendingVisualPulseTime)
{
visualPulseTimer = 0.18f;
pendingVisualPulseTime = -1.0f;
}
UpdateMetronomeVisual(now);
HandleInput(now);
}
private void HandleInput(float now)
{
bool primary = GetRightButton(XRCommonUsages.primaryButton) || IsKeyboardPressed(Key.Space);
bool secondary = GetRightButton(XRCommonUsages.secondaryButton) || IsKeyboardPressed(Key.Escape);
if (primary && !previousPrimary)
CaptureSample(now);
if (secondary && !previousSecondary)
Close();
previousPrimary = primary;
previousSecondary = secondary;
}
private void CaptureSample(float now)
{
float nearestTick = Mathf.Abs(now - lastTickTime) <= Mathf.Abs(now - nextTickTime)
? lastTickTime
: nextTickTime;
float offsetMs = Mathf.Clamp((now - nearestTick) * 1000.0f, -300.0f, 300.0f);
samples.Add(offsetMs);
if (samples.Count > MaxSamples)
samples.RemoveAt(0);
float sum = 0.0f;
for (int i = 0; i < samples.Count; i++)
sum += samples[i];
GlobalSyncSettings.AudioOffsetMs = sum / samples.Count;
UpdateTexts();
}
private void AdjustOffset(float deltaMs)
{
GlobalSyncSettings.AudioOffsetMs += deltaMs;
samples.Clear();
UpdateTexts();
}
private void ResetOffset()
{
GlobalSyncSettings.Reset();
samples.Clear();
UpdateTexts();
}
private void Close()
{
RestoreCanvases();
if (returnScene.IsValid() && returnScene.isLoaded)
SceneManager.SetActiveScene(returnScene);
if (syncScene.IsValid() && syncScene.isLoaded)
SceneManager.UnloadSceneAsync(syncScene);
else
Destroy(gameObject);
}
private void BuildView()
{
Camera camera = Camera.main ?? FindFirstObjectByType<Camera>();
GameObject canvasObject = new GameObject("SyncCalibrationCanvas");
canvasObject.transform.SetParent(transform, false);
Canvas canvas = canvasObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 400;
canvas.worldCamera = camera;
canvasObject.AddComponent<GraphicRaycaster>();
RectTransform canvasRect = canvasObject.GetComponent<RectTransform>();
canvasRect.sizeDelta = new Vector2(1080.0f, 660.0f);
canvasObject.transform.localScale = Vector3.one * 0.0028f;
if (camera != null)
{
Transform camTransform = camera.transform;
canvasObject.transform.position = camTransform.position + camTransform.forward * 1.9f;
canvasObject.transform.rotation = Quaternion.LookRotation(canvasObject.transform.position - camTransform.position, camTransform.up);
}
Image background = CreateImage("Panel", canvasRect, new Vector2(0, 0), new Vector2(1020, 620), new Color(0.02f, 0.06f, 0.09f, 0.92f));
background.raycastTarget = false;
TextMeshProUGUI title = CreateText("Title", canvasRect, "SYNC CALIBRATION", new Vector2(0, 260), new Vector2(920, 58), 42, Color.white, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
guideText = CreateText("Guide", canvasRect, "", new Vector2(0, 180), new Vector2(920, 92), 26, new Color(0.77f, 0.9f, 1.0f, 1.0f), TextAlignmentOptions.Center);
Image bar = CreateImage("BeatBar", canvasRect, new Vector2(0, 82), new Vector2(760, 10), new Color(0.25f, 0.85f, 1.0f, 0.28f));
bar.raycastTarget = false;
sweepDot = CreateImage("BeatDot", canvasRect, new Vector2(-380, 82), new Vector2(34, 34), new Color(0.35f, 0.95f, 1.0f, 1.0f)).rectTransform;
visualPulse = CreateImage("VisualPulse", canvasRect, new Vector2(0, 82), new Vector2(140, 140), new Color(0.35f, 0.95f, 1.0f, 0.0f)).rectTransform;
offsetText = CreateText("Offset", canvasRect, "", new Vector2(0, 0), new Vector2(720, 74), 54, Color.white, TextAlignmentOptions.Center);
offsetText.fontStyle = FontStyles.Bold;
sampleText = CreateText("Samples", canvasRect, "", new Vector2(0, -72), new Vector2(760, 44), 24, new Color(0.65f, 0.78f, 0.84f, 1.0f), TextAlignmentOptions.Center);
CreateButton(canvasRect, "-10ms", new Vector2(-300, -165), () => AdjustOffset(-10.0f));
CreateButton(canvasRect, "+10ms", new Vector2(-100, -165), () => AdjustOffset(10.0f));
CreateButton(canvasRect, "RESET", new Vector2(100, -165), ResetOffset);
CreateButton(canvasRect, "BACK", new Vector2(300, -165), Close);
CreateText("Footer", canvasRect, "A / Space: capture beat B / Esc: back", new Vector2(0, -270), new Vector2(860, 40), 22, new Color(0.58f, 0.7f, 0.75f, 1.0f), TextAlignmentOptions.Center);
}
private void UpdateMetronomeVisual(float now)
{
float phase = Mathf.InverseLerp(lastTickTime, nextTickTime, now);
if (sweepDot != null)
sweepDot.anchoredPosition = new Vector2(Mathf.Lerp(-380.0f, 380.0f, phase), 82.0f);
if (visualPulse == null)
return;
visualPulseTimer = Mathf.Max(0.0f, visualPulseTimer - Time.unscaledDeltaTime);
float alpha = visualPulseTimer / 0.18f;
visualPulse.sizeDelta = Vector2.one * Mathf.Lerp(190.0f, 80.0f, alpha);
Image image = visualPulse.GetComponent<Image>();
if (image != null)
image.color = new Color(0.35f, 0.95f, 1.0f, alpha * 0.52f);
}
private void UpdateTexts()
{
float offset = GlobalSyncSettings.AudioOffsetMs;
if (offsetText != null)
offsetText.text = $"{offset:+0;-0;0} ms";
if (sampleText != null)
sampleText.text = $"samples {samples.Count}/{MaxSamples} global offset saved";
if (guideText != null)
guideText.text = "Tick 소리가 들리는 순간 A / Space를 누르세요.\n파란 원이 박자와 겹치면 보정이 맞습니다.";
}
private void HideExistingCanvases()
{
hiddenCanvases.Clear();
foreach (Canvas canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
hiddenCanvases.Add((canvas, canvas.enabled));
canvas.enabled = false;
}
}
private void RestoreCanvases()
{
if (canvasesRestored)
return;
canvasesRestored = true;
foreach ((Canvas canvas, bool enabled) in hiddenCanvases)
{
if (canvas != null)
canvas.enabled = enabled;
}
}
private static TextMeshProUGUI CreateText(string name, RectTransform parent, string value, Vector2 position, Vector2 size, int fontSize, Color color, TextAlignmentOptions alignment)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
text.text = value;
text.fontSize = fontSize;
text.color = color;
text.alignment = alignment;
text.textWrappingMode = TextWrappingModes.Normal;
text.overflowMode = TextOverflowModes.Overflow;
text.raycastTarget = false;
return text;
}
private static Image CreateImage(string name, RectTransform parent, Vector2 position, Vector2 size, Color color)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
Image image = go.AddComponent<Image>();
image.color = color;
return image;
}
private static void CreateButton(RectTransform parent, string label, Vector2 position, UnityEngine.Events.UnityAction action)
{
Image image = CreateImage(label + "Button", parent, position, new Vector2(168, 58), new Color(0.07f, 0.18f, 0.24f, 0.96f));
Button button = image.gameObject.AddComponent<Button>();
button.onClick.AddListener(action);
ColorBlock colors = button.colors;
colors.normalColor = new Color(0.07f, 0.18f, 0.24f, 0.96f);
colors.highlightedColor = new Color(0.13f, 0.38f, 0.48f, 1.0f);
colors.pressedColor = new Color(0.08f, 0.72f, 0.85f, 1.0f);
colors.selectedColor = colors.highlightedColor;
button.colors = colors;
TextMeshProUGUI labelText = CreateText(label + "Text", image.rectTransform, label, Vector2.zero, new Vector2(154, 48), 24, Color.white, TextAlignmentOptions.Center);
labelText.fontStyle = FontStyles.Bold;
}
private static bool GetRightButton(InputFeatureUsage<bool> usage)
{
var devices = new List<XRInputDevice>();
InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller | InputDeviceCharacteristics.Right, devices);
if (devices.Count == 0)
return false;
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private static bool IsKeyboardPressed(Key key)
{
Keyboard keyboard = Keyboard.current;
return keyboard != null && keyboard[key].isPressed;
}
private static AudioClip CreateTickClip()
{
const int sampleRate = 48000;
const float duration = 0.055f;
int sampleCount = Mathf.CeilToInt(sampleRate * duration);
float[] data = new float[sampleCount];
for (int i = 0; i < sampleCount; i++)
{
float t = (float)i / sampleRate;
float envelope = Mathf.Exp(-t * 62.0f);
float high = Mathf.Sin(2.0f * Mathf.PI * 1760.0f * t);
float click = i < 80 ? 1.0f - (float)i / 80.0f : 0.0f;
data[i] = Mathf.Clamp((high * 0.75f + click * 0.35f) * envelope, -1.0f, 1.0f);
}
AudioClip clip = AudioClip.Create("SyncTick", sampleCount, 1, sampleRate, false);
clip.SetData(data, 0);
return clip;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 90f8d18d467240d8bb84178048a5aa91
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+248 -14
View File
@@ -11,10 +11,20 @@ namespace VRBeats
{
[SerializeField] private bool isRightHand = true;
[SerializeField] private float maxDistance = 50f;
[SerializeField] private bool debugLogging = false;
[SerializeField] private float scrollSpeed = 2.4f;
[SerializeField] private float scrollDeadZone = 0.15f;
[SerializeField] private float dragScrollSpeed = 1.25f;
[SerializeField] private float dragClickThreshold = 0.025f;
private LineRenderer _line;
private bool _prevTrigger;
private Selectable _currentHover;
private ScrollRect _dragScrollRect;
private Selectable _triggerPressSelectable;
private Vector2 _dragStartLocalPoint;
private float _dragStartNormalizedPosition;
private float _dragMaxNormalizedDelta;
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
@@ -40,6 +50,7 @@ namespace VRBeats
private void Start()
{
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
if (debugLogging)
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
}
@@ -56,12 +67,15 @@ namespace VRBeats
private void Update()
{
// 3초마다 연결된 디바이스 목록 출력
if (debugLogging)
{
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
}
bool trigger = GetButton(CommonUsages.triggerButton);
bool grip = GetButton(CommonUsages.gripButton);
@@ -70,31 +84,41 @@ namespace VRBeats
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick;
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
string hand = isRightHand ? "R" : "L";
if (debugLogging)
{
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown)Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward);
float hitDist = maxDistance;
float selectableHitDist = maxDistance;
float scrollHitDist = maxDistance;
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
bool beganScrollDrag = false;
if (triggerDown)
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
if (_dragScrollRect != null && trigger)
UpdateScrollDrag(ray);
else if (_dragScrollRect == null)
HandleScroll(scrollRect);
// 호버 변화 로그
if (hit != _currentHover)
if (debugLogging && hit != _currentHover)
{
Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}"
@@ -103,16 +127,21 @@ namespace VRBeats
UpdateHoverState(hit);
// 검지 트리거 또는 A/X 버튼으로 클릭
if (triggerDown || primaryDown)
if (triggerUp && _dragScrollRect != null)
EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{
if (_currentHover != null)
{
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover);
}
else
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
@@ -121,6 +150,12 @@ namespace VRBeats
}
DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
}
private void LogConnectedDevices()
@@ -222,6 +257,7 @@ namespace VRBeats
foreach (Selectable sel in Selectable.allSelectablesArray)
{
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue;
@@ -260,6 +296,189 @@ namespace VRBeats
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
}
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
{
ScrollRect closest = null;
float closestDist = maxDist;
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
foreach (ScrollRect scroll in all)
{
if (!scroll.isActiveAndEnabled) continue;
if (!IsOnEnabledCanvas(scroll)) continue;
RectTransform rt = scroll.viewport != null
? scroll.viewport
: scroll.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = scroll;
}
if (closest != null)
maxDist = closestDist;
return closest;
}
private static bool IsOnEnabledCanvas(Component component)
{
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
if (canvases.Length == 0)
return true;
for (int i = 0; i < canvases.Length; i++)
{
Canvas canvas = canvases[i];
if (canvas == null)
continue;
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
return false;
}
return true;
}
private void HandleScroll(ScrollRect scrollRect)
{
if (!CanScrollVertically(scrollRect))
return;
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
if (Mathf.Abs(axis.y) < scrollDeadZone)
return;
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
}
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
{
if (!CanScrollVertically(scrollRect))
return false;
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
return false;
_dragScrollRect = scrollRect;
_triggerPressSelectable = pressSelectable;
_dragStartLocalPoint = localPoint;
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
_dragMaxNormalizedDelta = 0f;
return true;
}
private void UpdateScrollDrag(Ray ray)
{
if (_dragScrollRect == null)
return;
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
return;
float deltaY = localPoint.y - _dragStartLocalPoint.y;
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
_dragStartNormalizedPosition - normalizedDelta);
}
private void EndScrollDrag(string hand, Ray ray)
{
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
ScrollRect scrollRect = _dragScrollRect;
Selectable pressSelectable = _triggerPressSelectable;
float startNormalizedPosition = _dragStartNormalizedPosition;
ClearScrollDrag();
if (!shouldClick)
return;
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
{
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
Click(pressSelectable);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(ray);
}
}
private void ClearScrollDrag()
{
_dragScrollRect = null;
_triggerPressSelectable = null;
_dragStartLocalPoint = Vector2.zero;
_dragStartNormalizedPosition = 0f;
_dragMaxNormalizedDelta = 0f;
}
private static bool CanScrollVertically(ScrollRect scrollRect)
{
if (scrollRect == null || !scrollRect.vertical)
return false;
RectTransform viewport = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (viewport == null || scrollRect.content == null)
return true;
return scrollRect.content.rect.height > viewport.rect.height + 1f;
}
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
{
localPoint = Vector2.zero;
viewportHeight = 1f;
RectTransform rt = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (rt == null)
return false;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
return false;
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
localPoint = new Vector2(local.x, local.y);
viewportHeight = Mathf.Max(1f, rt.rect.height);
return true;
}
private bool GetButton(InputFeatureUsage<bool> usage)
{
var chars = InputDeviceCharacteristics.Controller |
@@ -274,5 +493,20 @@ namespace VRBeats
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return Vector2.zero;
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
return axis;
}
}
}
+14 -1
View File
@@ -80,6 +80,9 @@ namespace VRBeats
if (!isRight && !isLeft) continue;
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
if (go.GetComponent<LineRenderer>() == null) continue;
DisableToolkitPointerComponents(go);
if (go.GetComponent<VRPointerController>() != null) continue;
var pointer = go.AddComponent<VRPointerController>();
@@ -94,8 +97,18 @@ namespace VRBeats
if (disabledByDefault)
pointer.enabled = false;
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
}
}
private static void DisableToolkitPointerComponents(GameObject go)
{
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
if (rayInteractor != null)
rayInteractor.enabled = false;
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
if (lineVisual != null)
lineVisual.enabled = false;
}
}
}
@@ -44,28 +44,6 @@ MonoBehaviour:
balance:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-8104416584915340131
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent2
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent2
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p21:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-7750755424749557576
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -239,19 +217,6 @@ MonoBehaviour:
maxNits:
m_OverrideState: 1
m_Value: 1000
--- !u!114 &-5360449096862653589
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedEverywhere
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedEverywhere
active: 1
--- !u!114 &-5139089513906902183
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -408,28 +373,6 @@ MonoBehaviour:
tint:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-581120513425526550
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent3
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent3
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p31:
m_OverrideState: 1
m_Value: {r: 0, g: 0, b: 0, a: 1}
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -852,19 +795,6 @@ MonoBehaviour:
intensity:
m_OverrideState: 1
m_Value: 0
--- !u!114 &6940869943325143175
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedOnAnySRP
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedOnAnySRP
active: 1
--- !u!114 &7173750748008157695
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -961,22 +891,3 @@ MonoBehaviour:
blueOutBlueIn:
m_OverrideState: 1
m_Value: 100
--- !u!114 &9122958982931076880
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent1
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent1
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
@@ -0,0 +1,6 @@
{
"host": "http://192.168.55.3:5000",
"publicHost": "http://whdwo798.synology.me",
"account": "",
"password": ""
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5f6f642b36f74dc5a0f44793fa605c2e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -33,7 +33,8 @@ MonoBehaviour:
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
m_autoSizeTextContainer: 0
m_IsTextObjectScaleStatic: 0
m_fallbackFontAssets: []
m_fallbackFontAssets:
- {fileID: 11400000, guid: f6c6fe0f3c5912a43a8a6707e336d2ea, type: 2}
m_matchMaterialPreset: 1
m_HideSubTextObjects: 1
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
+168
View File
@@ -0,0 +1,168 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: SkyBox
m_Shader: {fileID: 108, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords:
- _MAPPING_LATITUDE_LONGITUDE_LAYOUT
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BackTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DownTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _FrontTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _LeftTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _RightTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _UpTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _Exposure: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _ImageType: 0
- _Layout: 0
- _Mapping: 1
- _Metallic: 0
- _MirrorOnBack: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Rotation: 0
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1}
- _Tint: {r: 0.5, g: 0.5, b: 0.5, a: 0.5}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &5645475041611047199
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fecd661b14876064fa838cbb52ca425e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -76,7 +76,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -170,7 +170,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -528,7 +528,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -760,7 +760,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -854,7 +854,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -1212,7 +1212,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -411,7 +411,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668446538128435}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1003,7 +1003,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668447105775893}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1295,7 +1295,7 @@ Camera:
m_GameObject: {fileID: 8546668447772986810}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
@@ -342,7 +342,7 @@ MonoBehaviour:
handSettings:
interactPoint: {fileID: 3074267110786978836}
highlightPoint: {fileID: 3074267110786978836}
rotationOffset: {x: 0, y: 90, z: 25}
rotationOffset: {x: 25, y: 0, z: 0}
canInteract: 1
rightHandAnimationSettings:
animation: {fileID: 0}
@@ -522,7 +522,7 @@ MonoBehaviour:
m_GameObject: {fileID: 5575416034875238503}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c68346ff56573a1429560a527ad447e0, type: 3}
m_Script: {fileID: 11500000, guid: 4de824eda67bd1c4ba4d379a9debd2b3, type: 3}
m_Name:
m_EditorClassIdentifier:
fastCollisionListener: {fileID: 5407220909436794986}
@@ -533,6 +533,7 @@ MonoBehaviour:
hitForce: 0
maxHitForce: 0
canDismember: 0
colorSide: 1
--- !u!114 &5407220909436794986
MonoBehaviour:
m_ObjectHideFlags: 0
+82 -82
View File
@@ -156,8 +156,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -18, y: 19.5}
m_SizeDelta: {x: 30, y: 7}
m_AnchoredPosition: {x: -19, y: 19.5}
m_SizeDelta: {x: 34, y: 7}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &11933137
MonoBehaviour:
@@ -456,8 +456,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 4}
m_SizeDelta: {x: 50, y: 0.4}
m_AnchoredPosition: {x: 0, y: 4.2}
m_SizeDelta: {x: 68, y: 0.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &97527770
MonoBehaviour:
@@ -652,7 +652,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -1267,8 +1267,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 12}
m_SizeDelta: {x: 50, y: 6}
m_AnchoredPosition: {x: -16.05, y: 10.8}
m_SizeDelta: {x: 42, y: 4.8}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &285384529
MonoBehaviour:
@@ -1290,7 +1290,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text:
m_text: Anesthesia
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
@@ -1299,7 +1299,7 @@ MonoBehaviour:
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
rgba: 3439329279
m_fontColor: {r: 1, g: 1, b: 1, a: 0.8}
m_enableVertexGradient: 0
m_colorMode: 3
@@ -1312,7 +1312,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -1324,7 +1324,7 @@ MonoBehaviour:
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
@@ -1405,8 +1405,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -26.6, y: -9.4}
m_SizeDelta: {x: 52.7, y: 49}
m_AnchoredPosition: {x: -44, y: -9.4}
m_SizeDelta: {x: 56, y: 49}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &313102848
GameObject:
@@ -1682,7 +1682,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &365318615
RectTransform:
m_ObjectHideFlags: 0
@@ -1830,16 +1830,16 @@ RectTransform:
m_GameObject: {fileID: 365636951}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1536039027}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 12, y: -14}
m_SizeDelta: {x: 22, y: 7}
m_AnchoredPosition: {x: 17, y: -9.7}
m_SizeDelta: {x: 27, y: 6}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &365636953
MonoBehaviour:
@@ -1957,7 +1957,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.1, y: -9.4}
m_AnchoredPosition: {x: -15, y: -9.4}
m_SizeDelta: {x: 0.5, y: 49}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &393692317
@@ -2300,16 +2300,16 @@ RectTransform:
m_GameObject: {fileID: 549476134}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 138927897}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -12, y: -5}
m_SizeDelta: {x: 22, y: 7}
m_AnchoredPosition: {x: -17, y: -2}
m_SizeDelta: {x: 27, y: 6}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &549476136
MonoBehaviour:
@@ -2428,7 +2428,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 15.5}
m_SizeDelta: {x: 104, y: 0.5}
m_SizeDelta: {x: 144, y: 0.5}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &584186484
MonoBehaviour:
@@ -2534,7 +2534,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -0.54999924, y: 0.040000916}
m_AnchoredPosition: {x: -0.55000305, y: 0.040000916}
m_SizeDelta: {x: 1.1, y: 3.12}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &624828134
@@ -2639,8 +2639,8 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 661667650}
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 19.76}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_LocalPosition: {x: 0, y: 0, z: 21.44}
m_LocalScale: {x: 0.21, y: 0.21, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 140294464}
@@ -2652,7 +2652,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 24.54, y: 4.415}
m_AnchoredPosition: {x: 28.85, y: 4.71}
m_SizeDelta: {x: 105.885, y: 71.226}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &661667652
@@ -2786,8 +2786,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 14, y: 19.5}
m_SizeDelta: {x: 30, y: 7}
m_AnchoredPosition: {x: 17.5, y: 19.5}
m_SizeDelta: {x: 34, y: 7}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &711973508
MonoBehaviour:
@@ -3087,7 +3087,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -3224,7 +3224,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -3310,16 +3310,16 @@ RectTransform:
m_GameObject: {fileID: 848577108}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2121890138}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 12, y: -5}
m_SizeDelta: {x: 22, y: 7}
m_AnchoredPosition: {x: 17, y: -2}
m_SizeDelta: {x: 27, y: 6}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &848577110
MonoBehaviour:
@@ -3437,8 +3437,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 7}
m_SizeDelta: {x: 50, y: 5}
m_AnchoredPosition: {x: 20.5, y: 10.8}
m_SizeDelta: {x: 28, y: 4.8}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &861505383
MonoBehaviour:
@@ -3460,7 +3460,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text:
m_text: BPM 120
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
@@ -3469,7 +3469,7 @@ MonoBehaviour:
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
rgba: 2583691263
m_fontColor: {r: 1, g: 1, b: 1, a: 0.6}
m_enableVertexGradient: 0
m_colorMode: 3
@@ -3482,7 +3482,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -3494,7 +3494,7 @@ MonoBehaviour:
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
@@ -3611,8 +3611,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -3, y: 18.5}
m_SizeDelta: {x: 38, y: 8}
m_AnchoredPosition: {x: -5.5, y: 17}
m_SizeDelta: {x: 63, y: 7.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &921515098
MonoBehaviour:
@@ -3634,7 +3634,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: ---
m_text: Oxlo
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
@@ -3656,13 +3656,13 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 6.5
m_fontSizeBase: 6.5
m_fontSize: 7.2
m_fontSizeBase: 7.2
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -3817,16 +3817,16 @@ RectTransform:
m_GameObject: {fileID: 967100893}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 829525444}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 16.400002, y: -22.900002}
m_SizeDelta: {x: 16, y: 7}
m_AnchoredPosition: {x: 22.5, y: -20.2}
m_SizeDelta: {x: 18, y: 6.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &967100895
MonoBehaviour:
@@ -4019,8 +4019,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -18.5}
m_SizeDelta: {x: 50, y: 0.4}
m_AnchoredPosition: {x: 0, y: -16}
m_SizeDelta: {x: 68, y: 0.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1010856770
MonoBehaviour:
@@ -4095,7 +4095,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 23.5}
m_SizeDelta: {x: 104, y: 0.5}
m_SizeDelta: {x: 144, y: 0.5}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1014388332
MonoBehaviour:
@@ -4255,7 +4255,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
m_IsActive: 1
--- !u!224 &1223157292
RectTransform:
m_ObjectHideFlags: 0
@@ -4265,7 +4265,7 @@ RectTransform:
m_GameObject: {fileID: 1223157291}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.89393, y: 0.89393, z: 0.89393}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1293606945}
@@ -4287,8 +4287,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 26.6, y: -9.4}
m_SizeDelta: {x: 52.7, y: 49}
m_AnchoredPosition: {x: 29, y: -9.4}
m_SizeDelta: {x: 86, y: 49}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1223157293
MonoBehaviour:
@@ -4486,7 +4486,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -4656,8 +4656,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 21, y: 20.5}
m_SizeDelta: {x: 8, y: 7}
m_AnchoredPosition: {x: 37, y: 20.5}
m_SizeDelta: {x: 7, y: 6.5}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1293606946
MonoBehaviour:
@@ -4769,16 +4769,16 @@ RectTransform:
m_GameObject: {fileID: 1388756479}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1245764537}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -9.5, y: -22.7}
m_SizeDelta: {x: 34, y: 7}
m_AnchoredPosition: {x: -12.5, y: -20.2}
m_SizeDelta: {x: 40, y: 6.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1388756481
MonoBehaviour:
@@ -5026,16 +5026,16 @@ RectTransform:
m_GameObject: {fileID: 1436526096}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.9, y: 0.9, z: 0.9}
m_ConstrainProportionsScale: 1
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1786385005}
m_Father: {fileID: 1223157292}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -12, y: -14}
m_SizeDelta: {x: 22, y: 7}
m_AnchoredPosition: {x: -17, y: -9.7}
m_SizeDelta: {x: 27, y: 6}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1436526098
MonoBehaviour:
@@ -5241,8 +5241,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.67, y: 4.46}
m_SizeDelta: {x: 105.885, y: 68.223}
m_AnchoredPosition: {x: 0.4, y: 4.5}
m_SizeDelta: {x: 148, y: 68.2}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1445586371
MonoBehaviour:
@@ -5493,7 +5493,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -5933,7 +5933,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 28.5}
m_SizeDelta: {x: 100, y: 9}
m_SizeDelta: {x: 138, y: 9}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1754869934
MonoBehaviour:
@@ -6233,7 +6233,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -6626,7 +6626,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -6720,8 +6720,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -6, y: -21.5}
m_SizeDelta: {x: 34, y: 7}
m_AnchoredPosition: {x: -12.5, y: -20.2}
m_SizeDelta: {x: 40, y: 6.4}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1888615411
MonoBehaviour:
@@ -7157,8 +7157,8 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1946485404}
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 17.9}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_LocalPosition: {x: 0, y: 0, z: 20.96}
m_LocalScale: {x: 0.21, y: 0.21, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2121492652}
@@ -7167,7 +7167,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -22.2, y: 4.39}
m_AnchoredPosition: {x: -27.37, y: 4.39}
m_SizeDelta: {x: 105.89, y: 66.53}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1946485406
@@ -7727,7 +7727,7 @@ MonoBehaviour:
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
@@ -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;
}
}
@@ -39,6 +39,11 @@ namespace VRBeats
if (Cut(info.hitPoint, cutDir, insideMaterial))
{
Color trailColor = beatCube != null
? VR_BeatManager.instance.GetColorFromColorSide(beatCube.ThisColorSide)
: Color.white;
Vector3 saberUp = beatDamageInfo.hitObject != null ? beatDamageInfo.hitObject.transform.up : cutDir;
SliceTrailEffect.Spawn(info.hitPoint, info.hitDir, saberUp, trailColor);
Destroy(gameObject);
}
}
@@ -0,0 +1,467 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace VRBeats
{
/// <summary>
/// Builds a short world-space ribbon from the whole saber blade instead of
/// attaching a TrailRenderer to the tip. This keeps the afterimage on the
/// blade surface and avoids the "propeller" look from a single end point.
/// </summary>
public class SaberTrailEffect : MonoBehaviour
{
private const int MaxSamples = 18;
private const float TrailLifetime = 0.13f;
private const float MinSwingSpeed = 0.75f;
private const float MinSampleDistance = 0.012f;
private const float BladeBaseT = 0.10f;
private const float BladeTipT = 0.98f;
private readonly List<BladeSample> samples = new List<BladeSample>(MaxSamples);
private readonly List<Vector3> vertices = new List<Vector3>(MaxSamples * 2);
private readonly List<Color> colors = new List<Color>(MaxSamples * 2);
private readonly List<Vector2> uvs = new List<Vector2>(MaxSamples * 2);
private readonly List<int> triangles = new List<int>((MaxSamples - 1) * 12);
private Transform bladeStart;
private Transform bladeEnd;
private GameObject wideObject;
private GameObject coreObject;
private Mesh wideMesh;
private Mesh coreMesh;
private MeshRenderer wideRenderer;
private MeshRenderer coreRenderer;
private Material wideMaterial;
private Material coreMaterial;
private Color trailColor = Color.cyan;
private bool visible = true;
private bool hasLastFrame;
private Vector3 lastBase;
private Vector3 lastTip;
private struct BladeSample
{
public Vector3 basePos;
public Vector3 tipPos;
public float time;
}
private void Awake()
{
RemoveLegacyTrailRenderers();
ResolveBladeAnchors();
EnsureRenderers();
SetColor(trailColor);
SetVisible(visible);
}
private void LateUpdate()
{
if (!visible)
{
Clear();
hasLastFrame = false;
return;
}
if (bladeEnd == null)
{
ResolveBladeAnchors();
}
Vector3 basePos;
Vector3 tipPos;
GetBladeSegment(out basePos, out tipPos);
float now = Time.time;
float speed = 0f;
float moved = 0f;
if (hasLastFrame)
{
float deltaTime = Mathf.Max(Time.deltaTime, 0.0001f);
speed = Mathf.Max(
Vector3.Distance(basePos, lastBase),
Vector3.Distance(tipPos, lastTip)) / deltaTime;
moved = Mathf.Max(
Vector3.Distance(basePos, lastBase),
Vector3.Distance(tipPos, lastTip));
}
if (!hasLastFrame || (speed >= MinSwingSpeed && moved >= MinSampleDistance))
{
AddSample(basePos, tipPos, now);
}
TrimExpiredSamples(now);
BuildRibbon(wideMesh, now, 0f, 1f, trailColor, 0.10f, 0.36f);
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;
hasLastFrame = true;
}
private void OnDisable()
{
Clear();
hasLastFrame = false;
}
private void OnDestroy()
{
DestroyRuntimeObject(wideObject);
DestroyRuntimeObject(coreObject);
DestroyRuntimeObject(wideMesh);
DestroyRuntimeObject(coreMesh);
DestroyRuntimeObject(wideMaterial);
DestroyRuntimeObject(coreMaterial);
}
public void SetVisible(bool value)
{
visible = value;
if (wideRenderer != null)
{
wideRenderer.enabled = value;
}
if (coreRenderer != null)
{
coreRenderer.enabled = value;
}
if (!value)
{
Clear();
hasLastFrame = false;
}
}
public void SetColor(Color color)
{
trailColor = NormalizeColor(color);
EnsureRenderers();
float alphaMultiplier = VisibilityAlphaMultiplier(trailColor);
ApplyMaterialColor(wideMaterial, trailColor, 0.34f * alphaMultiplier);
Color coreColor = Color.Lerp(Color.white, trailColor, CoreColorWeight(trailColor));
ApplyMaterialColor(coreMaterial, coreColor, 0.50f * alphaMultiplier);
}
private void ResolveBladeAnchors()
{
bladeStart = FindChildRecursive(transform, "Start");
bladeEnd = FindChildRecursive(transform, "End");
if (bladeEnd == null)
{
bladeEnd = transform;
}
if (bladeStart == null)
{
bladeStart = transform;
}
}
private void GetBladeSegment(out Vector3 basePos, out Vector3 tipPos)
{
Vector3 start = bladeStart != null ? bladeStart.position : transform.position;
Vector3 end = bladeEnd != null ? bladeEnd.position : transform.position + transform.forward;
if (Vector3.Distance(start, end) < 0.001f)
{
end = start + transform.forward * 0.8f;
}
basePos = Vector3.Lerp(start, end, BladeBaseT);
tipPos = Vector3.Lerp(start, end, BladeTipT);
}
private void AddSample(Vector3 basePos, Vector3 tipPos, float time)
{
if (samples.Count >= MaxSamples)
{
samples.RemoveAt(0);
}
samples.Add(new BladeSample
{
basePos = basePos,
tipPos = tipPos,
time = time
});
}
private void TrimExpiredSamples(float now)
{
for (int i = samples.Count - 1; i >= 0; i--)
{
if (now - samples[i].time > TrailLifetime)
{
samples.RemoveAt(i);
}
}
}
private void BuildRibbon(Mesh mesh, float now, float innerT, float outerT, Color color, float innerAlpha, float outerAlpha)
{
if (mesh == null)
{
return;
}
vertices.Clear();
colors.Clear();
uvs.Clear();
triangles.Clear();
if (samples.Count < 2)
{
mesh.Clear();
return;
}
for (int i = 0; i < samples.Count; i++)
{
BladeSample sample = samples[i];
float age01 = Mathf.Clamp01((now - sample.time) / TrailLifetime);
float fade = Mathf.Pow(1f - age01, 1.7f);
float along = samples.Count <= 1 ? 0f : i / (float)(samples.Count - 1);
Vector3 inner = Vector3.Lerp(sample.basePos, sample.tipPos, innerT);
Vector3 outer = Vector3.Lerp(sample.basePos, sample.tipPos, outerT);
vertices.Add(inner);
vertices.Add(outer);
colors.Add(WithAlpha(color, fade * innerAlpha));
colors.Add(WithAlpha(color, fade * outerAlpha));
uvs.Add(new Vector2(0f, along));
uvs.Add(new Vector2(1f, along));
}
for (int i = 0; i < samples.Count - 1; i++)
{
int a = i * 2;
int b = a + 1;
int c = a + 2;
int d = a + 3;
triangles.Add(a);
triangles.Add(b);
triangles.Add(c);
triangles.Add(b);
triangles.Add(d);
triangles.Add(c);
triangles.Add(c);
triangles.Add(b);
triangles.Add(a);
triangles.Add(c);
triangles.Add(d);
triangles.Add(b);
}
mesh.Clear();
mesh.SetVertices(vertices);
mesh.SetColors(colors);
mesh.SetUVs(0, uvs);
mesh.SetTriangles(triangles, 0);
mesh.RecalculateBounds();
}
private void EnsureRenderers()
{
if (wideObject == null)
{
wideRenderer = CreateRibbonObject("SaberBladeAfterimage_Wide", out wideObject, out wideMesh, out wideMaterial, false);
}
if (coreObject == null)
{
coreRenderer = CreateRibbonObject("SaberBladeAfterimage_Core", out coreObject, out coreMesh, out coreMaterial, true);
}
}
private MeshRenderer CreateRibbonObject(string objectName, out GameObject ribbonObject, out Mesh mesh, out Material material, bool additive)
{
ribbonObject = new GameObject(objectName);
ribbonObject.hideFlags = HideFlags.DontSave;
ribbonObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
ribbonObject.transform.localScale = Vector3.one;
mesh = new Mesh
{
name = objectName + " Mesh"
};
mesh.MarkDynamic();
MeshFilter filter = ribbonObject.AddComponent<MeshFilter>();
filter.sharedMesh = mesh;
MeshRenderer renderer = ribbonObject.AddComponent<MeshRenderer>();
material = CreateTrailMaterial(objectName + " Material", additive);
renderer.sharedMaterial = material;
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.receiveShadows = false;
renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
renderer.enabled = visible;
return renderer;
}
private Material CreateTrailMaterial(string materialName, bool additive)
{
Shader shader = Shader.Find("Sprites/Default");
if (shader == null)
{
shader = Shader.Find("Unlit/Transparent");
}
Material material = new Material(shader)
{
name = materialName,
hideFlags = HideFlags.DontSave,
renderQueue = (int)RenderQueue.Transparent
};
if (material.HasProperty("_SrcBlend"))
{
material.SetInt("_SrcBlend", additive ? (int)BlendMode.SrcAlpha : (int)BlendMode.SrcAlpha);
}
if (material.HasProperty("_DstBlend"))
{
material.SetInt("_DstBlend", additive ? (int)BlendMode.One : (int)BlendMode.OneMinusSrcAlpha);
}
if (material.HasProperty("_ZWrite"))
{
material.SetInt("_ZWrite", 0);
}
return material;
}
private void ApplyMaterialColor(Material material, Color color, float alpha)
{
if (material == null)
{
return;
}
Color materialColor = WithAlpha(color, alpha);
if (material.HasProperty("_Color"))
{
material.SetColor("_Color", materialColor);
}
}
private void Clear()
{
samples.Clear();
if (wideMesh != null)
{
wideMesh.Clear();
}
if (coreMesh != null)
{
coreMesh.Clear();
}
}
private void RemoveLegacyTrailRenderers()
{
TrailRenderer[] legacyTrails = GetComponentsInChildren<TrailRenderer>(true);
for (int i = legacyTrails.Length - 1; i >= 0; i--)
{
DestroyRuntimeObject(legacyTrails[i]);
}
}
private static Transform FindChildRecursive(Transform root, string childName)
{
if (root == null)
{
return null;
}
if (root.name == childName)
{
return root;
}
for (int i = 0; i < root.childCount; i++)
{
Transform found = FindChildRecursive(root.GetChild(i), childName);
if (found != null)
{
return found;
}
}
return null;
}
private static Color NormalizeColor(Color color)
{
float max = Mathf.Max(color.r, color.g, color.b);
if (max > 1f)
{
color.r /= max;
color.g /= max;
color.b /= max;
}
color.a = 1f;
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);
return color;
}
private static void DestroyRuntimeObject(Object target)
{
if (target == null)
{
return;
}
if (Application.isPlaying)
{
Destroy(target);
}
else
{
DestroyImmediate(target);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f4ab89ec5474db497559af138ed0a6f
@@ -0,0 +1,152 @@
using System.Collections;
using UnityEngine;
using UnityEngine.Rendering;
namespace VRBeats
{
public class SliceTrailEffect : MonoBehaviour
{
private const float Lifetime = 0.24f;
private const int PointCount = 7;
private LineRenderer glowLine = null;
private LineRenderer coreLine = null;
private Vector3 center = Vector3.zero;
private Vector3 tangent = Vector3.right;
private Vector3 lift = Vector3.up;
private Color effectColor = Color.cyan;
public static void Spawn(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
{
GameObject effectObject = new GameObject("SliceTrailEffect");
SliceTrailEffect effect = effectObject.AddComponent<SliceTrailEffect>();
effect.Construct(position, hitDir, saberUp, color);
}
private void Construct(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
{
center = position;
transform.position = position;
effectColor = NormalizeColor(color);
tangent = saberUp.sqrMagnitude > 0.001f ? saberUp.normalized : Vector3.right;
lift = hitDir.sqrMagnitude > 0.001f ? hitDir.normalized : Vector3.up;
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());
}
private LineRenderer CreateLine(string name, float width, float alpha)
{
GameObject lineObject = new GameObject(name);
lineObject.transform.SetParent(transform, false);
LineRenderer line = lineObject.AddComponent<LineRenderer>();
line.positionCount = PointCount;
line.useWorldSpace = true;
line.alignment = LineAlignment.View;
line.textureMode = LineTextureMode.Stretch;
line.numCornerVertices = 8;
line.numCapVertices = 8;
line.shadowCastingMode = ShadowCastingMode.Off;
line.receiveShadows = false;
line.material = CreateMaterial();
line.widthMultiplier = width;
ApplyGradient(line, alpha);
return line;
}
private IEnumerator Animate()
{
float age = 0.0f;
while (age < Lifetime)
{
float t = age / Lifetime;
float length = Mathf.Lerp(0.72f, 1.45f, t);
float bend = Mathf.Lerp(0.12f, 0.34f, t);
float alpha = 1.0f - t;
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;
}
Destroy(gameObject);
}
private void UpdateLine(LineRenderer line, float length, float bend, float alpha)
{
if (line == null)
return;
for (int i = 0; i < PointCount; i++)
{
float normalized = PointCount <= 1 ? 0.0f : (float)i / (PointCount - 1);
float offset = normalized - 0.5f;
float curve = Mathf.Sin(normalized * Mathf.PI) * bend;
line.SetPosition(i, center + tangent * (offset * length) + lift * curve);
}
ApplyGradient(line, alpha);
}
private static Material CreateMaterial()
{
Shader shader = Shader.Find("Sprites/Default");
if (shader == null)
shader = Shader.Find("Unlit/Transparent");
Material material = new Material(shader);
material.name = "Runtime Slice Trail";
return material;
}
private void ApplyGradient(LineRenderer line, float alpha)
{
Color start = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
Color mid = new Color(effectColor.r, effectColor.g, effectColor.b, alpha);
Color end = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
Gradient gradient = new Gradient();
gradient.SetKeys(
new[]
{
new GradientColorKey(start, 0.0f),
new GradientColorKey(mid, 0.5f),
new GradientColorKey(end, 1.0f),
},
new[]
{
new GradientAlphaKey(0.0f, 0.0f),
new GradientAlphaKey(alpha, 0.5f),
new GradientAlphaKey(0.0f, 1.0f),
});
line.colorGradient = gradient;
}
private static Color NormalizeColor(Color color)
{
float max = Mathf.Max(color.r, Mathf.Max(color.g, color.b));
if (max > 1.0f)
color /= max;
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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 41e40f1c9d0c48af9e2ad90cb8c5108c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -56,10 +56,12 @@ namespace VRBeats
//notify to whoever is listening that the player did a correct/incorrect slice
if ( IsCutIntentValid(info as BeatDamageInfo) )
{
ScoreManager.ReportSliceTiming(GetTimingErrorSeconds());
onCorrectSlice.Invoke();
}
else
{
ScoreManager.ReportMiss();
onIncorrectSlice.Invoke();
}
@@ -98,6 +100,7 @@ namespace VRBeats
public void Kill()
{
ScoreManager.ReportMiss();
onPlayerMiss.Invoke();
canBeKilled = false;
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate
@@ -107,6 +110,13 @@ namespace VRBeats
} );
}
private float GetTimingErrorSeconds()
{
float speed = Mathf.Max(Mathf.Abs(thisSpawneable.Speed), 0.001f);
float distanceFromPlayer = Mathf.Abs(transform.position.z - player.position.z);
return distanceFromPlayer / speed;
}
}
@@ -1,6 +1,8 @@
using UnityEngine;
using System.Collections;
using UnityEngine;
using Platinio;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using VRBeats.ScriptableEvents;
using VRSDK;
@@ -60,24 +62,22 @@ namespace VRBeats
Vector3 finalPosition = CalculateSpawnPosition( info.position);
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
Vector3 spawnPosition = finalPosition - travelOffset;
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
SetSpeedRelativeToPlayZone(info);
clone.Construct(info);
Vector3 finalScale = clone.transform.localScale;
clone.transform.localScale = Vector3.zero;
float travelTime = info.travelTimeOverride > 0f ? info.travelTimeOverride : settings.TargetTravelTime;
clone.transform.Move(finalPosition, travelTime).SetEase(settings.TargetTravelEase).SetOnComplete(delegate
info.speed = settings.TargetTravelDistance / Mathf.Max(0.05f, travelTime);
SetSpeedRelativeToPlayZone(info);
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
clone.Construct(info);
StartCoroutine(BeginContinuousSpawnNextFrame(clone));
}
private IEnumerator BeginContinuousSpawnNextFrame(Spawneable clone)
{
yield return null;
if (clone != null)
clone.OnSpawn();
}).SetUpdateMode(Platinio.TweenEngine.UpdateMode.Update);
clone.transform.ScaleTween(finalScale, travelTime).SetEase(settings.TargetTravelEase);
}
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
@@ -122,13 +122,7 @@ namespace VRBeats
public void RestartLevel()
{
gameObject.CancelAllTweens();
isGameRunning = true;
audioManager.SetAudioMixerPitch(1.0f);
enviromentController.TurnLightsOn();
playableDirector.time = 0.0f;
playableDirector.Play();
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
@@ -39,9 +39,9 @@ namespace VRBeats
public void EnableXRRayInteractorComponents()
{
if (rayInteractor != null)
rayInteractor.enabled = true;
rayInteractor.enabled = false;
if (interactorLineVisual != null)
interactorLineVisual.enabled = true;
interactorLineVisual.enabled = false;
if (lineRender != null)
lineRender.enabled = true;
@@ -15,10 +15,14 @@ namespace VRBeats
private VR_Grabbable grabbable = null;
private ColorSide colorSide = ColorSide.Left;
private MeshRenderer[] renderArray = null;
private SaberTrailEffect trailEffect = null;
private void Awake()
{
renderArray = transform.GetComponentsInChildren<MeshRenderer>();
trailEffect = GetComponent<SaberTrailEffect>();
if (trailEffect == null)
trailEffect = gameObject.AddComponent<SaberTrailEffect>();
grabbable = GetComponent<VR_Grabbable>();
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
@@ -44,6 +48,9 @@ namespace VRBeats
{
SetMaterialBindings(materialBindingArray[n], c);
}
if (trailEffect != null)
trailEffect.SetColor(c);
}
private void SetMaterialBindings(MaterialBindings matBindings, Color c)
@@ -55,11 +62,15 @@ namespace VRBeats
public void MakeVisible()
{
SetRenderArrayEnableValue(true);
if (trailEffect != null)
trailEffect.SetVisible(true);
}
public void MakeInvisible()
{
SetRenderArrayEnableValue(false);
if (trailEffect != null)
trailEffect.SetVisible(false);
}
private void SetRenderArrayEnableValue(bool value)
@@ -13,24 +13,42 @@ namespace VRBeats
private void Start()
{
if (colorSide == ColorSide.Left) controller = VR_Manager.instance.Player.LeftController;
if (colorSide == ColorSide.Right) controller = VR_Manager.instance.Player.RightController;
ResolveController();
}
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
{
ResolveController();
var damageInfo = base.CreateDamageInfo(hitPoint);
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo);
Vector3 controllerVelocity = controller.Velocity;
Vector3 controllerVelocity = controller != null ? controller.Velocity : Vector3.zero;
beatDamageInfo.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
beatDamageInfo.hitObject = gameObject;
beatDamageInfo.colorSide = colorSide;
beatDamageInfo.velocity = controller.Velocity.magnitude;
beatDamageInfo.velocity = controllerVelocity.magnitude;
return beatDamageInfo;
}
private void ResolveController()
{
VR_Grabbable grabbable = GetComponent<VR_Grabbable>();
if (grabbable != null && grabbable.GrabController != null)
{
controller = grabbable.GrabController;
colorSide = controller.ControllerType == VR_ControllerType.Right ? ColorSide.Right : ColorSide.Left;
return;
}
if (VR_Manager.instance == null || VR_Manager.instance.Player == null)
return;
controller = colorSide == ColorSide.Left
? VR_Manager.instance.Player.LeftController
: VR_Manager.instance.Player.RightController;
}
}
}
+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;
}
}
}
@@ -32,7 +32,7 @@ namespace VRBeats
public Vector3 rotation = Vector3.zero;
public float speed = 2.0f;
public int speedMultiplier = 1;
// 0 이면 Settings.TargetTravelTime 사용, 양수면 해당 시간로 이동
// 0이면 Settings.TargetTravelTime 사용, 양수면 해당 시간 동안 일정 속도로 이동
public float travelTimeOverride = 0f;
}
}
+298 -18
View File
@@ -1,5 +1,4 @@
using UnityEngine;
using Platinio.TweenEngine;
using UnityEngine;
using TMPro;
namespace VRBeats
@@ -7,54 +6,335 @@ 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 rankBackText = null;
private TextMeshProUGUI rankShadowText = null;
private TextMeshProUGUI rankDepthText = null;
private TextMeshProUGUI rankRimText = null;
private TextMeshProUGUI rankMainText = null;
private TextMeshProUGUI rankHighlightText = null;
private TextMeshProUGUI resultScoreText = null;
private TextMeshProUGUI resultAccuracyText = null;
private TextMeshProUGUI resultComboText = null;
private CanvasGroup scoreHudCanvasGroup = null;
private void Awake()
{
for (int n = 0; n < length; n++)
{
initialValue += "0";
}
scoreManager = FindFirstObjectByType<ScoreManager>();
if (scoreManager != null)
scoreHudCanvasGroup = scoreManager.GetComponent<CanvasGroup>() ??
scoreManager.gameObject.AddComponent<CanvasGroup>();
ApplyPopupTextStyle();
BuildResultLayout();
}
public void ShowScore()
{
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , scoreFadeTime).SetOnUpdateFloat(delegate (float v)
if (scoreText == null || scoreManager == null)
return;
SetTitleActive(false);
SetScoreHudVisible(false);
gameObject.CancelAllTweens();
if (resultRoot != null)
{
SetScore( (int)v );
});
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);
SetScoreHudVisible(true);
ApplyPopupTextStyle();
if (scoreText != null)
scoreText.text = initialValue;
}
private void SetScore(int score)
private void PopulateResultLayout()
{
if (this.scoreText == null)
if (scoreManager == null ||
rankBackText == null ||
rankShadowText == null ||
rankDepthText == null ||
rankRimText == null ||
rankMainText == null ||
rankHighlightText == null ||
resultScoreText == null ||
resultAccuracyText == 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++)
string rank = scoreManager.Rank;
Color mainColor = HexToColor(scoreManager.RankColorHex);
Color depthColor = HexToColor(GetRankDepthColorHex(rank));
Color rimColor = HexToColor(GetRankRimColorHex(rank));
rankBackText.text = rank;
rankShadowText.text = rank;
rankDepthText.text = rank;
rankDepthText.color = depthColor;
rankRimText.text = rank;
rankMainText.text = rank;
rankHighlightText.text = rank;
ApplyMetalRankColors(mainColor, depthColor, rimColor);
resultScoreText.text =
$"<size=56%><color=#A0C8FF>SCORE</color></size>\n{scoreManager.CurrentScore:N0}";
resultAccuracyText.text =
$"<color=#A0C8FF>ACCURACY</color> {scoreManager.AccuracyPercent:0.0}%";
resultComboText.text = $"MAX COMBO {scoreManager.MaxCombo}";
}
private void SetTitleActive(bool active)
{
addZeros += "0";
Transform titleObj = scoreText != null
? scoreText.rectTransform.parent?.Find("Title")
: null;
if (titleObj != null)
titleObj.gameObject.SetActive(active);
}
this.scoreText.text = addZeros + scoreText;
private void ApplyPopupTextStyle()
{
if (scoreText == null)
return;
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 = -10.0f;
scoreText.color = Color.white;
scoreText.richText = true;
}
private void SetScoreHudVisible(bool visible)
{
if (scoreHudCanvasGroup == null)
return;
scoreHudCanvasGroup.alpha = visible ? 1.0f : 0.0f;
scoreHudCanvasGroup.interactable = false;
scoreHudCanvasGroup.blocksRaycasts = false;
}
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 = new Vector2(5.0f, 3.1f);
rootRect.sizeDelta = new Vector2(82.0f, 34.0f);
root.SetActive(false);
resultRoot = root;
// Panel-local coordinates are small world-canvas units, not screen pixels.
rankBackText = MakeTmpLabel(root.transform, "RankBackText",
new Vector2(-20.9f, -1.1f), new Vector2(37.0f, 27.0f), 16.0f,
new Color(0.0f, 0.0f, 0.0f, 0.48f), TextAlignmentOptions.Midline);
rankShadowText = MakeTmpLabel(root.transform, "RankShadowText",
new Vector2(-21.35f, -0.55f), new Vector2(37.0f, 27.0f), 16.0f,
new Color(0.0f, 0.06f, 0.14f, 0.82f), TextAlignmentOptions.Midline);
rankDepthText = MakeTmpLabel(root.transform, "RankDepthText",
new Vector2(-21.8f, -0.1f), new Vector2(37.0f, 27.0f), 16.0f,
Color.white, TextAlignmentOptions.Midline);
rankRimText = MakeTmpLabel(root.transform, "RankRimText",
new Vector2(-22.15f, 0.22f), new Vector2(37.0f, 27.0f), 16.0f,
Color.white, TextAlignmentOptions.Midline);
rankMainText = MakeTmpLabel(root.transform, "RankMainText",
new Vector2(-22.45f, 0.5f), new Vector2(37.0f, 27.0f), 16.0f,
Color.white, TextAlignmentOptions.Midline);
rankHighlightText = MakeTmpLabel(root.transform, "RankHighlightText",
new Vector2(-22.85f, 1.0f), new Vector2(37.0f, 27.0f), 16.0f,
Color.white, TextAlignmentOptions.Midline);
resultScoreText = MakeTmpLabel(root.transform, "ResultScoreText",
new Vector2(16.8f, 7.4f), new Vector2(43.0f, 10.8f), 5.35f,
Color.white, TextAlignmentOptions.MidlineLeft);
resultAccuracyText = MakeTmpLabel(root.transform, "ResultAccuracyText",
new Vector2(16.8f, -1.2f), new Vector2(43.0f, 5.4f), 3.05f,
new Color(0.84f, 0.97f, 1.0f, 0.9f), TextAlignmentOptions.MidlineLeft);
resultComboText = MakeTmpLabel(root.transform, "ResultComboText",
new Vector2(16.8f, -6.8f), new Vector2(43.0f, 5.4f), 2.95f,
new Color(0.84f, 0.97f, 1.0f, 1.0f), TextAlignmentOptions.MidlineLeft);
ConfigureRankLayer(rankBackText, new Color(0.0f, 0.0f, 0.0f, 0.72f), 0.34f);
ConfigureRankLayer(rankShadowText, new Color(0.0f, 0.0f, 0.0f, 0.74f), 0.2f);
ConfigureRankLayer(rankDepthText, new Color(0.0f, 0.0f, 0.0f, 0.55f), 0.13f);
ConfigureRankLayer(rankRimText, new Color(1.0f, 1.0f, 1.0f, 0.82f), 0.08f);
ConfigureRankLayer(rankMainText, new Color(0.0f, 0.16f, 0.28f, 0.7f), 0.06f);
ConfigureRankLayer(rankHighlightText, new Color(1.0f, 1.0f, 1.0f, 0.35f), 0.02f);
}
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.enableAutoSizing = true;
tmp.fontSizeMin = fontSize * 0.6f;
tmp.fontSizeMax = fontSize;
tmp.color = color;
tmp.alignment = align;
tmp.overflowMode = TextOverflowModes.Truncate;
tmp.textWrappingMode = TextWrappingModes.NoWrap;
tmp.lineSpacing = -4.0f;
tmp.raycastTarget = false;
if (scoreText != null && scoreText.font != null)
{
tmp.font = scoreText.font;
tmp.fontSharedMaterial = scoreText.fontSharedMaterial;
}
return tmp;
}
private static void ConfigureRankLayer(TextMeshProUGUI tmp, Color outlineColor, float outlineWidth)
{
if (tmp == null)
return;
tmp.fontSizeMin = tmp.fontSize * 0.75f;
tmp.outlineColor = outlineColor;
tmp.outlineWidth = outlineWidth;
tmp.characterSpacing = -1.0f;
}
private void ApplyMetalRankColors(Color mainColor, Color depthColor, Color rimColor)
{
Color darkMetal = new Color(0.02f, 0.08f, 0.14f, 0.82f);
Color steel = new Color(0.70f, 0.95f, 1.0f, 1.0f);
Color whiteHot = new Color(1.0f, 1.0f, 1.0f, 1.0f);
Color lowerMain = Color.Lerp(mainColor, depthColor, 0.55f);
SetSolidRankLayer(rankBackText, new Color(0.0f, 0.0f, 0.0f, 0.50f));
SetSolidRankLayer(rankShadowText, darkMetal);
SetRankGradient(rankDepthText,
Color.Lerp(depthColor, steel, 0.22f),
depthColor,
new Color(0.0f, 0.12f, 0.22f, 0.95f),
new Color(0.0f, 0.05f, 0.10f, 0.95f));
SetRankGradient(rankRimText,
whiteHot,
rimColor,
Color.Lerp(rimColor, mainColor, 0.35f),
Color.Lerp(mainColor, depthColor, 0.45f));
SetRankGradient(rankMainText,
whiteHot,
Color.Lerp(whiteHot, rimColor, 0.45f),
Color.Lerp(mainColor, steel, 0.18f),
lowerMain);
SetRankGradient(rankHighlightText,
new Color(1.0f, 1.0f, 1.0f, 0.42f),
new Color(0.92f, 1.0f, 1.0f, 0.28f),
new Color(1.0f, 1.0f, 1.0f, 0.08f),
new Color(1.0f, 1.0f, 1.0f, 0.02f));
}
private static void SetSolidRankLayer(TextMeshProUGUI tmp, Color color)
{
if (tmp == null)
return;
tmp.enableVertexGradient = false;
tmp.color = color;
}
private static void SetRankGradient(TextMeshProUGUI tmp,
Color topLeft, Color topRight, Color bottomLeft, Color bottomRight)
{
if (tmp == null)
return;
tmp.enableVertexGradient = true;
tmp.color = Color.white;
tmp.colorGradient = new VertexGradient(topLeft, topRight, bottomLeft, bottomRight);
}
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 string GetRankRimColorHex(string rank)
{
switch (rank)
{
case "M": return "#FFFFFF";
case "S+": return "#E8FFFF";
case "S": return "#FFF4B8";
case "A": return "#F1FFD8";
case "B": return "#FFF5B8";
case "C": return "#FFE0B8";
case "D": return "#FFD5D5";
default: return "#E8F0F8";
}
}
private static Color HexToColor(string hex)
{
if (ColorUtility.TryParseHtmlString(hex, out Color color))
return color;
return Color.white;
}
}
}
+549 -85
View File
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using Platinio.TweenEngine;
using VRBeats.ScriptableEvents;
@@ -7,6 +7,14 @@ namespace VRBeats
{
public class ScoreManager : MonoBehaviour
{
private enum BeatJudgement
{
Perfect,
Great,
Good,
Miss
}
[SerializeField] private Text multiplierLabel = null;
[SerializeField] private Text scoreLabel = null;
[SerializeField] private Image multiplierLoader = null;
@@ -14,61 +22,221 @@ namespace VRBeats
[SerializeField] private CanvasGroup canvasGroup = null;
[SerializeField] private GameEvent onGameOver = null;
[Header("DJMAX Style Score")]
[SerializeField] private Text comboLabel = null;
[SerializeField] private Text accuracyLabel = null;
[SerializeField] private Text judgementLabel = null;
[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.11f;
[SerializeField] private float greatWindow = 0.20f;
[SerializeField] private float goodWindow = 0.32f;
private int maxMultiplier = 0;
private int scorePerHit = 0;
private int currentScore = 0;
private int currentMultiplier = 0;
private int toNextMultiplierIncrease = 2;
private int acumulateCorrectSlices = 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;
private int totalNoteCount = 0;
private int judgedNoteCount = 0;
private int currentCombo = 0;
private int maxCombo = 0;
private int perfectCount = 0;
private int greatCount = 0;
private int goodCount = 0;
private int missCount = 0;
private int earnedAccuracyPoints = 0;
private float visualScore = 0.0f;
private int scoreTweenID = -1;
private int loaderTweenID = -1;
private BeatJudgement lastJudgement = BeatJudgement.Perfect;
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;
private bool resultFinalized = false;
private bool destroyed = false;
private static bool hasPendingSliceTiming = false;
private static float pendingSliceTiming = 0.0f;
private static Font hudFont = null;
private Image ringBackground = null;
public int CurrentScore
{
get
{
return currentScore;
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
{
int denominatorNotes = totalNoteCount > 0 ? totalNoteCount : judgedNoteCount;
if (denominatorNotes <= 0)
return 100.0f;
return (float)earnedAccuracyPoints / (denominatorNotes * 1000) * 100.0f;
}
}
public string Rank
{
get
{
if (CurrentScore >= MaxCourseScore) return "M";
float accuracy = AccuracyPercent;
if (accuracy >= 98.0f) return "S+";
if (accuracy >= 95.0f) return "S";
if (accuracy >= 90.0f) return "A";
if (accuracy >= 80.0f) return "B";
if (accuracy >= 70.0f) return "C";
if (accuracy >= 60.0f) return "D";
return "F";
}
}
public int MaxCombo => maxCombo;
public string RankColorHex => GetRankColorHex();
private void Awake()
{
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
canvasGroup ??= GetComponent<CanvasGroup>() ?? gameObject.AddComponent<CanvasGroup>();
PrepareHud();
}
public static void ReportSliceTiming(float timingErrorSeconds)
{
pendingSliceTiming = timingErrorSeconds;
hasPendingSliceTiming = true;
}
public static void ReportMiss()
{
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
}
public void SetTotalNotes(int noteCount)
{
totalNoteCount = Mathf.Max(0, noteCount);
resultFinalized = false;
UpdateScoreTween();
}
public void ApplyForcedResult(int noteCount, int perfect, int great, int good, int miss, int forcedMaxCombo)
{
totalNoteCount = Mathf.Max(0, noteCount);
perfectCount = Mathf.Max(0, perfect);
greatCount = Mathf.Max(0, great);
goodCount = Mathf.Max(0, good);
missCount = Mathf.Max(0, miss);
judgedNoteCount = perfectCount + greatCount + goodCount + missCount;
earnedAccuracyPoints = perfectCount * 1000 + greatCount * 900 + goodCount * 700;
maxCombo = Mathf.Clamp(forcedMaxCombo, 0, Mathf.Max(totalNoteCount, judgedNoteCount));
currentCombo = maxCombo;
currentMultiplier = missCount > 0 ? 1.0f : GetComboMultiplier(currentCombo);
lastJudgement = missCount > 0 ? BeatJudgement.Miss : BeatJudgement.Perfect;
judgementTimer = 0.45f;
resultFinalized = false;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
}
public void CompleteSong()
{
if (resultFinalized)
return;
resultFinalized = true;
int missedUnjudgedNotes = Mathf.Max(0, totalNoteCount - judgedNoteCount);
if (missedUnjudgedNotes > 0)
{
missCount += missedUnjudgedNotes;
judgedNoteCount += missedUnjudgedNotes;
currentCombo = 0;
currentMultiplier = 1.0f;
}
UpdateScoreTween();
}
public void SetSongProgress(float currentTime, float duration)
{
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();
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
{
canvasGroup.alpha = 0.0f;
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
}
SetSaberVisibility(false);
}
public void OnGameRestart()
{
ResetThisComponent();
gameObject.CancelAllTweens();
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
{
canvasGroup.alpha = 1.0f;
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = false;
}
SetSaberVisibility(true);
}
public void ResetThisComponent()
{
currentMultiplier = 0;
currentScore = 0;
acumulateCorrectSlices = 0;
currentMultiplier = 1.0f;
visualScore = 0;
acumulateErrors = 0;
toNextMultiplierIncrease = 2;
judgedNoteCount = 0;
currentCombo = 0;
maxCombo = 0;
perfectCount = 0;
greatCount = 0;
goodCount = 0;
missCount = 0;
earnedAccuracyPoints = 0;
judgementTimer = 0.0f;
resultFinalized = false;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
}
private void Update()
{
UpdateUI();
@@ -79,46 +247,13 @@ namespace VRBeats
if (destroyed)
return;
acumulateErrors = 0;
acumulateCorrectSlices++;
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
BeatJudgement judgement = ConsumeJudgement();
RegisterJudgement(judgement);
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
visualScore = value;
}).ID;
acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateCorrectSlices >= toNextMultiplierIncrease)
{
IncreaseMultiplier();
}
}
private void CancelTweenById(int id)
{
if(id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed)
return;
float multiplierLoaderValue = (float)acumulateCorrectSlices / (float)toNextMultiplierIncrease;
CancelTweenById(loaderTweenID);
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).ID;
}
public void OnIncorrectSlice()
@@ -126,18 +261,57 @@ namespace VRBeats
if (destroyed)
return;
RegisterJudgement(BeatJudgement.Miss);
acumulateErrors++;
acumulateCorrectSlices = 0;
currentMultiplier = 0;
toNextMultiplierIncrease = 2;
currentMultiplier = 1.0f;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateErrors > errorLimit)
{
onGameOver.Invoke();
}
public string BuildResultSummary(int minScoreLength)
{
string score = CurrentScore.ToString("N0");
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)
{
if (id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed || multiplierLoader == null)
return;
float multiplierLoaderValue = GetComboTierProgress();
CancelTweenById(loaderTweenID);
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f)
.SetEase(Ease.EaseOutExpo)
.SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
})
.SetOwner(multiplierLoader.gameObject)
.ID;
}
private void UpdateScoreTween()
{
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, CurrentScore, scoreFollowTime)
.SetEase(Ease.EaseOutExpo)
.SetOnUpdateFloat(delegate (float value) { visualScore = value; })
.ID;
}
private void UpdateUI()
@@ -145,41 +319,331 @@ namespace VRBeats
if (destroyed)
return;
multiplierLabel.text = currentMultiplier.ToString();
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString();
}
if (multiplierLabel != null)
multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}";
if (scoreLabel != null)
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
if (comboLabel != null)
comboLabel.text = currentCombo > 0
? $"<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)
rankLabel.text = $"<color={GetRankColorHex()}>{Rank}</color>";
if (progressLabel != null)
progressLabel.text = songDuration > 0.0f
? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}"
: "";
if (progressBarFill != null)
SetProgressBarFill(songDuration > 0.0f
? Mathf.Clamp01(songCurrentTime / songDuration)
: 0.0f);
private void IncreaseMultiplier()
{
if (destroyed)
if (judgementLabel == null)
return;
acumulateCorrectSlices = 0;
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier );
toNextMultiplierIncrease = (currentMultiplier + 1) * 2;
PlatinioTween.instance.CancelTween(multiplierLoader.gameObject);
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 1.0f, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).SetOnComplete( delegate
{
if (multiplierLabel != null)
{
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject);
judgementTimer -= Time.deltaTime;
judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : "";
judgementLabel.color = GetJudgementColor(lastJudgement);
}
} );
private BeatJudgement ConsumeJudgement()
{
if (!hasPendingSliceTiming)
return BeatJudgement.Perfect;
float timing = pendingSliceTiming;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (timing <= perfectWindow) return BeatJudgement.Perfect;
if (timing <= greatWindow) return BeatJudgement.Great;
if (timing <= goodWindow) return BeatJudgement.Good;
return BeatJudgement.Good;
}
private void RegisterJudgement(BeatJudgement judgement)
{
lastJudgement = judgement;
judgementTimer = 0.45f;
judgedNoteCount++;
if (judgement == BeatJudgement.Perfect)
{
perfectCount++;
earnedAccuracyPoints += 1000;
currentCombo++;
}
else if (judgement == BeatJudgement.Great)
{
greatCount++;
earnedAccuracyPoints += 900;
currentCombo++;
}
else if (judgement == BeatJudgement.Good)
{
goodCount++;
earnedAccuracyPoints += 700;
currentCombo++;
}
else
{
missCount++;
currentCombo = 0;
}
maxCombo = Mathf.Max(maxCombo, currentCombo);
currentMultiplier = judgement == BeatJudgement.Miss
? 1.0f
: Mathf.Min(GetComboMultiplier(currentCombo), Mathf.Max(1.0f, maxMultiplier));
PulseComboLabel(judgement);
}
private static float GetComboMultiplier(int combo)
{
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;
}
private static string GetJudgementText(BeatJudgement judgement)
{
switch (judgement)
{
case BeatJudgement.Perfect: return "PERFECT";
case BeatJudgement.Great: return "GREAT";
case BeatJudgement.Good: return "GOOD";
default: return "BREAK";
}
}
private static Color GetJudgementColor(BeatJudgement judgement)
{
switch (judgement)
{
case BeatJudgement.Perfect: return new Color(0.25f, 0.95f, 1.0f, 1.0f);
case BeatJudgement.Great: return new Color(0.58f, 1.0f, 0.45f, 1.0f);
case BeatJudgement.Good: return new Color(1.0f, 0.8f, 0.35f, 1.0f);
default: return new Color(1.0f, 0.25f, 0.45f, 1.0f);
}
}
private float GetComboTierProgress()
{
int lower = 0;
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);
}
private void PrepareHud()
{
RectTransform rect = transform as RectTransform;
if (applyHudPlacement && rect != null)
rect.anchoredPosition = hudAnchoredPosition;
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");
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)
{
GameObject textObject = new GameObject(name);
textObject.layer = gameObject.layer;
textObject.transform.SetParent(transform, false);
RectTransform rect = textObject.AddComponent<RectTransform>();
textObject.AddComponent<CanvasRenderer>();
Text text = textObject.AddComponent<Text>();
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);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
text.fontSize = fontSize;
text.color = color;
text.alignment = alignment;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
text.supportRichText = true;
text.lineSpacing = 0.86f;
Shadow shadow = text.GetComponent<Shadow>() ?? text.gameObject.AddComponent<Shadow>();
shadow.effectColor = new Color(0.0f, 0.0f, 0.0f, 0.72f);
shadow.effectDistance = new Vector2(3.0f, -3.0f);
}
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;
RectTransform rect = image.rectTransform;
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
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 "#FFD95C";
case "A": return "#B9FF72";
case "B": return "#FFE06A";
case "C": return "#FFB15C";
case "D": return "#FF7C7C";
default: return "#A9B7C0";
}
}
private void PulseComboLabel(BeatJudgement judgement)
{
if (comboLabel == null || judgement == BeatJudgement.Miss)
return;
comboLabel.gameObject.CancelAllTweens();
comboLabel.transform.localScale = comboBaseScale * 1.08f;
comboLabel.transform.ScaleTween(comboBaseScale, 0.16f).SetEase(Ease.EaseOutExpo).SetOwner(comboLabel.gameObject);
}
private static string FormatTime(float seconds)
{
int wholeSeconds = Mathf.Max(0, Mathf.FloorToInt(seconds));
int minutes = wholeSeconds / 60;
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();
}
}
}
}
+3 -3
View File
@@ -12,11 +12,11 @@ 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: 100
glowIntensity: 40
targetTravelDistance: 40
targetTravelTime: 1.8
targetTravelTime: 3.2
targetTravelEase: 19
errorLimit: 7
scorePerHit: 50
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: f47e26c36f77476ba803b5158d1b30da
VideoClipImporter:
externalObjects: {}
serializedVersion: 3
frameRange: 0
startFrame: -1
endFrame: -1
colorSpace: 0
deinterlace: 0
encodeAlpha: 0
flipVertical: 0
flipHorizontal: 0
importAudio: 1
targetSettings: {}
userData:
assetBundleName:
assetBundleVariant:
+108
View File
@@ -0,0 +1,108 @@
# Claude Review Request: Game Scene HUD Polish
## Status
This proposal has mostly been implemented and superseded by the current UI-quality pass.
Implemented since this proposal:
- ScoreCanvas was moved closer to the player.
- Left/right HUD structure was created.
- Multiplier ring background and song progress bar were added.
- Song current time / total time is displayed.
- Result screen polish has become the current priority.
Remaining HUD follow-up:
- Verify HUD readability in Play Mode.
- Consider migrating HUD labels from legacy `UI.Text` to TMP after the result screen is stable.
- Add rank/multiplier micro animations only after static layout is approved.
## 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.
+26
View File
@@ -0,0 +1,26 @@
# 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
Current quality focus:
1. Polish the result screen UI first.
2. Then polish the in-game HUD.
3. Then consider song select UI.
Do not change code unless the review gate above is satisfied.
+176 -445
View File
@@ -1,501 +1,232 @@
# VR Beat Saber 프로젝트 인수인계 문서
# VR Beat Saber Handoff
## 개요
## Project Status
Meta Quest용 VR Beat Saber 클론. Beat Sage API로 노래를 자동 채보하고, Synology NAS에 업로드/다운로드한다.
이 문서는 **기존 프로젝트(구버전)를 VRBeatsKit 기반 새 프로젝트로 이전**하기 위한 인수인계 자료다.
- Unity: `6000.3.12f1`
- Branch: `master`
- Remote: `origin = https://whdwo798.synology.me/whdwo798/BeatSaber.git`
- Latest pushed commit before this document update: `fb59fc3 feat: polish result screen UI`
- Current focus: raise overall UI quality, starting from the result screen, then HUD, then song select.
---
## Collaboration Rule
## 현재 상태 (2026-05-26)
- Code changes should go through a Codex and Claude Code mutual review gate.
- A proposal should score at least 80/100 before implementation.
- Review criteria:
- Correctness
- Scope control
- Unity scene/runtime risk
- Maintainability
- Verification plan
현재 저장소는 VRBeatsKit 기반 프로젝트 위에 커스텀 곡 선택/생성/NAS 연동 흐름을 붙인 상태다.
See `COLLABORATION_RULES.md`.
- Unity 버전: `6000.3.12f1`
- 현재 브랜치: `main`
- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git`
- 최근 푸시 커밋: `182d2c9 fix: stabilize VR UI and song playback`
- `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개
- 현재 워킹트리에는 큐브 간격 보정과 경고 제거 작업이 커밋 전 변경으로 남아 있다.
## Unity / MCP Bridge
### 실제 씬 구성
- `Assets/Editor/UnityCodexBridgeServer.cs`
- Bridge now falls back across ports `19744-19748` when the default port is busy.
- `tools/unity-mcp-server/index.mjs`
- MCP server probes the same fallback port range when `UNITY_BRIDGE_URL` is not explicitly set.
- This was added because Unity reported that the bridge socket address was already in use.
현재 Build Settings는 아래 순서다.
## Game Scene HUD
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`
### ScoreCanvas Placement
문서 아래쪽에 남아 있는 `Intro -> SongSelect -> Game -> SongCreator` 흐름은 목표 설계에 가깝다. 현재 실제 진입점은 VRBeatsKit `Menu.unity`이며, 그 안의 `SongSelect` 패널이 커스텀 곡 선택 UI 역할을 한다.
`Assets/Scenes/Game.unity`
### 현재 구현된 주요 흐름
- `ScoreCanvas` was moved closer to the player:
- Z: `17.8 -> 5`
- Scale: `0.005 -> 0.006`
- `applyHudPlacement`: `true -> false`
- This prevents HUD elements from collapsing visually near the center of the tunnel.
### Runtime HUD
`Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`
- Left HUD:
- Combo
- Score
- Accuracy
- Current rank
- Right HUD:
- Multiplier value
- Multiplier ring and faint ring background
- Song progress bar
- Current time / total time
- Dynamic legacy `Text` labels lazily load `LegacyRuntime.ttf` to avoid missing-font rendering issues.
- The progress bar fills from `0 -> 1` as the song progresses.
## Timing / Completion
`Assets/VRBeatsKit/Scripts/Core/AudioManager.cs`
- Song time now uses scheduled DSP time instead of reading `AudioSource.time`.
- This avoids the Unity warning about requesting `time` from an audio source whose resource is not a clip.
`Assets/Script/SongController.cs`
- Song duration falls back to metadata or last note time when `clip.length` is zero.
- Completion waits until the last note / known duration instead of ending early because of a bad clip length.
- Note layer Y positions were raised so low notes are easier to slice:
```text
Menu.unity / SongSelect
-> DownloadManager가 NAS 정적 서버의 songs.json 로드
-> SongDetailPanel에서 곡/난이도 다운로드
-> GameSession.SelectedSong / SelectedDifficulty 설정
-> Game.unity 로드
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
```
### 최근 반영된 변경
## Scoring / Rank Rules
- `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` 추가.
`Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`
### 현재 주의사항
### Score Formula
1. `Assets/StreamingAssets/nas_config.json`은 현재 저장소에 없다. NAS 업로드를 테스트하려면 로컬에 직접 만들어야 하며, 절대 커밋하지 않는다.
2. `SongCreator.unity`의 직렬화된 `nasBaseUrl` 값에 끝 공백이 들어가 있다: `http://whdwo798.synology.me:5000 `. 런타임에서 `nas_config.json`으로 덮어쓰지 않으면 로그인 URL 문제가 날 수 있다.
3. `SongCreatorManager`는 난이도 토글 필드를 갖고 있지만 현재 로직은 4개 난이도(`normal`, `hard`, `expert`, `expertplus`)를 항상 전부 생성한다.
4. `manualEditorButton`은 씬에서 미연결이고 코드에서도 사용하지 않는다.
5. `Assets/img/360.mp4`는 약 192MB다. 현재 일반 Git으로 푸시되어 있으므로, 원격 정책이 바뀌면 Git LFS 전환을 검토해야 한다.
6. 큐브 간격은 수치상 겹침을 피하도록 넓혔지만, 실제 Quest 착용 테스트에서 손 위치/판정 거리/시야 피로도를 확인해야 한다.
7. SongCreator에서 생성 직후 첫 재생이 곡에 따라 늦거나 싱크가 흔들리는 체감이 있었다. 게임 씬 오디오 기준은 `AudioSettings.dspTime`으로 개선했지만, 생성/다운로드/첫 로드 전체 파이프라인은 추가 로그 검증이 필요하다.
---
## 기존 프로젝트 소스 코드
**기존 프로젝트 전체 파일은 아래 git 저장소에서 가져온다.**
```
https://whdwo798.synology.me/whdwo798/BeatSaber.git
```text
CurrentScore = 800000 * accuracyRatio + 200000 * comboRatio
```
```bash
git clone https://whdwo798.synology.me/whdwo798/BeatSaber.git
- Max score: `1,000,000`
- Accuracy contribution: `800,000`
- Max combo contribution: `200,000`
### Judgement Points
```text
Perfect = 1000
Great = 900
Good = 700
Miss = 0
```
> 단, 이 저장소는 구버전 프로젝트다. 새 프로젝트는 VRBeatsKit을 기반으로 재구성하며,
> 위 저장소의 `Assets/Script/` 등 핵심 스크립트를 참고/이식하는 용도로 사용한다.
### Timing Windows
---
## Git 설정 (새 프로젝트)
새 Unity 프로젝트를 생성한 뒤 **가장 먼저** git을 초기화하고 파일을 커밋해야 한다.
Claude Code는 대화 시작 시 `git status` / `git log`를 자동으로 읽어 컨텍스트를 파악한다.
커밋이 없으면 Claude가 변경 이력을 추적할 수 없다.
### 초기화 순서
```bash
# 새 프로젝트 루트에서
git init
git remote add origin <GitHub 저장소 URL>
```text
Perfect <= 0.11s
Great <= 0.20s
Good <= 0.32s
```
### .gitignore
### Combo Multiplier
기존 프로젝트의 `.gitignore`를 복사하면 된다. 핵심 규칙:
The multiplier is intentionally beginner-friendly:
```gitignore
# Unity 표준
/Library/
/Temp/
/Obj/
/Build/
/Builds/
/Logs/
/UserSettings/
# NAS 비밀번호 — 절대 커밋 금지
/Assets/StreamingAssets/nas_config.json
/Assets/StreamingAssets/nas_config.json.meta
```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
```
### 첫 커밋
### Rank Thresholds
파일 복사 완료 후:
- `M`: final score `>= 1,000,000`
- Other ranks use `AccuracyPercent`:
```bash
git add .
git commit -m "init: VRBeatsKit 기반 프로젝트 초기 설정"
```text
S+ >= 98%
S >= 95%
A >= 90%
B >= 80%
C >= 70%
D >= 60%
F < 60%
```
이후 기능 단위로 커밋하면 Claude가 `git log`로 작업 이력을 파악한다.
### Rank Colors
---
## 새 프로젝트 구성 방법
### 전제 조건
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 큐브로 교체)
```text
M #E8B7FF prism / jewel tone
S+ #41F2FF cyan neon
S #FFD95C gold
A #B9FF72
B #FFE06A
C #FFB15C
D #FF7C7C
F #A9B7C0
```
### 복사 제외 (VRBeatsKit으로 대체)
## Result Screen
```
Assets/Script/Saber.cs → VRBeatsKit VR_Saber.cs 사용
Assets/Script/Cube.cs → VRBeatsKit VR_BeatCube.cs 사용
`Assets/VRBeatsKit/Scripts/UI/FinalScoreLabel.cs`
The result screen is now the first major UI-quality target.
Current result layout is generated at runtime:
```text
ResultLayoutRoot
RankShadowText
RankDepthText
RankMainText
ResultScoreText
ResultAccuracyText
ResultComboText
```
---
Implemented:
## 전체 씬 구성
- Existing `Title` is hidden while result content is shown.
- Existing serialized `scoreText` is hidden and used as a font/material source.
- Rank uses layered TMP text for a more dimensional badge.
- Result info now shows:
- `SCORE`
- final score
- `ACCURACY`
- max combo
- Result popup and restart/back buttons were tinted toward a darker translucent cyan UI style.
```
Intro → SongSelect → Game
SongSelect → SongCreator → (NAS 업로드) → SongSelect
```
Known follow-up:
| 씬 | 역할 |
|---|---|
| Intro | 로고 → SongSelect 자동 전환 |
| SongSelect | NAS에서 songs.json 로드, 곡 목록 표시, 다운로드/플레이 |
| Game | 음악 재생 + 큐브 스폰 + 점수/HP + 결과 화면 |
| SongCreator | 음악 파일 선택 → Beat Sage API 채보 → NAS 업로드 |
| MapEditorScene | 맵 에디터 (선택적) |
- `tmp.lineSpacing = -8.0f` is aggressive and may need visual tuning if the `SCORE` label overlaps the score value.
- Button styling is improved by color, but does not yet have a dedicated neon border/glow treatment.
- The scene appears to contain both result and game-over popup/button sets; verify that shared color changes are intentional.
---
## Visual Effects
## 전체 데이터 흐름
Blue visibility improvements were made in:
```
[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
- `Assets/VRBeatsKit/Settings/Settings.asset`
- `Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs`
- `Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs`
- `Assets/VRBeatsKit/Scripts/Other/Spark.cs`
[SongSelect]
→ DownloadManager: NAS에서 songs.json 로드
→ 사용자 곡 선택 → GameSession.SelectedSong, GameSession.SelectedDifficulty 설정
→ 다운로드: {id}.mp3 + Map_{id}_{diff}.json → Application.temporaryCachePath/beatsaber/{id}/
Goal:
[Game]
→ Spawner.InitGame(): 캐시에서 오디오/맵 로드
→ VRBeatsKit AudioManager AudioSource에 클립 세팅
→ 카운트다운 3→2→1→GO
→ 매 프레임: audioSource.time 기준으로 VR_BeatManager.Spawn() 호출
→ ScoreManager: 히트/미스 집계 → HP → 결과 화면
```
- Keep red strong.
- Make blue saber/trail/spark readable against the bright cyan tunnel.
---
## UI Quality Direction
## 주요 스크립트 역할
Current agreed direction:
### 복사하는 스크립트 (수정 없음)
1. Finish result screen polish first.
2. Then polish in-game HUD.
3. Then consider HUD migration from legacy `UI.Text` to TMP for sharper VR rendering.
4. Then redesign song select cards/details.
| 파일 | 역할 |
|---|---|
| `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 프리팹 주입. |
Design principles:
### 현재 미이식/미확인 스크립트
- Thin readable text.
- Dark translucent panels, not heavy opaque boxes.
- White/cyan baseline UI with rank color accents.
- Large values, small labels.
- Avoid clutter in the note highway.
- Add micro animations only after the static layout is readable.
| 파일 | 내용 |
|---|---|
| `IntroManager.cs` | 현재 저장소에 없음. 인트로 씬 흐름을 살릴 경우 작성/이식 필요. |
| `ScoreManager.cs`, `ScoreHUD.cs`, `ResultsPanel.cs` | 전역 네임스페이스 커스텀 점수 UI는 현재 저장소에 없음. 현재는 VRBeatsKit `VRBeats.ScoreManager`와 이벤트 자산을 사용. |
| `SaberGlow.cs`, `SaberSkinSelector.cs`, `CacheManager.cs` | 현재 저장소에 없음. 필요 시 기존 프로젝트에서 이식. |
## Verification Notes
---
- `git diff --check` passed for the latest result UI changes.
- `dotnet build beatSaber.slnx --no-incremental` is not reliable for this Unity project in the current environment because regular .NET build cannot resolve many Unity/TMP/UI/XR assemblies.
- Prefer Unity Editor compile and Play Mode for final verification.
## Game 실행부 현재 구현
## Recommended Next Checks
기존 인수인계 문서에는 `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. Play the Game scene and finish a song.
2. Check result screen layout for `M`, `S+`, `S`, and `F`.
3. Verify score, accuracy, and max combo do not overlap.
4. Confirm Restart and Back buttons remain clickable.
5. Check whether result popup and game-over popup color changes should both stay.
6. If result screen is stable, move to HUD polish and TMP migration discussion.
+65
View File
@@ -0,0 +1,65 @@
# Unity MCP Bridge
The project now has a local Unity Editor bridge plus a Node MCP server.
## Flow
```text
Codex MCP tool
-> tools/unity-mcp-server/index.mjs
-> http://127.0.0.1:19744
-> Assets/Editor/UnityCodexBridgeServer.cs
-> Unity Editor scene, logs, and camera capture
```
The Unity bridge binds only to `127.0.0.1`.
## Unity menu
```text
Tools > Codex Bridge > Start Server
Tools > Codex Bridge > Stop Server
Tools > Codex Bridge > Auto Start
Tools > Codex Bridge > Capture Game View
```
Auto Start is enabled by default.
## MCP setup
Install Node dependencies:
```powershell
cd tools\unity-mcp-server
npm install
```
Add this to Codex MCP config and restart Codex/VS Code:
```toml
[mcp_servers.vrbeats_unity]
command = "node"
args = ["C:\\Users\\User-40\\Desktop\\unity\\work\\BeatSabar\\VRBeatSaber\\tools\\unity-mcp-server\\index.mjs"]
startup_timeout_sec = 30
[mcp_servers.vrbeats_unity.env]
UNITY_BRIDGE_URL = "http://127.0.0.1:19744"
```
## Useful bridge URLs
These can be tested in a browser or with PowerShell while Unity is open:
```text
http://127.0.0.1:19744/health
http://127.0.0.1:19744/logs?count=50
http://127.0.0.1:19744/scene/roots
http://127.0.0.1:19744/scene/objects?query=Canvas&limit=50
http://127.0.0.1:19744/capture?width=1280&height=720
```
Screenshots are saved to:
```text
Captures/latest.png
```
+197 -19
View File
@@ -64,6 +64,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<button class="tab-btn" onclick="show('detail')">SongDetailPanel.cs</button>
<button class="tab-btn" onclick="show('selectmgr')">SongSelectManager.cs</button>
<button class="tab-btn" onclick="show('marquee')">MarqueeText.cs</button>
<button class="tab-btn" onclick="show('vrpointer')">VRPointerController.cs</button>
<button class="tab-btn" onclick="show('vrptrsetup')">VRPointerSetup.cs</button>
<h2>유틸</h2>
<button class="tab-btn" onclick="show('songlibrary')">SongLibrary.cs</button>
<button class="tab-btn" onclick="show('desktop')">DesktopUIMode.cs</button>
@@ -1002,6 +1004,13 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">GameEvent</span> onLevelComplete;
[<span class="ty">SerializeField</span>] <span class="kw">private</span> <span class="ty">TMP_Text</span> countdownText;
<span class="ann">// Beat Saber 4열/3행 좌표를 VRBeatsKit PlayZone 상대 좌표로 매핑하는 값</span>
<span class="ann">// 기존 0.25 간격은 큐브 실제 폭보다 좁아 인접 라인이 가로로 겹쳤다</span>
<span class="kw">private const float</span> LaneSpacing = <span class="nm">0.42f</span>;
<span class="kw">private const float</span> LayerSpacing = <span class="nm">0.38f</span>;
<span class="kw">private const float</span> HorizontalCenter = <span class="nm">1.5f</span>;
<span class="kw">private const float</span> VerticalCenter = <span class="nm">1f</span>;
<span class="kw">private</span> <span class="ty">AudioManager</span> _audio; <span class="ann">// VRBeatsKit AudioManager: 실제 AudioSource 래핑</span>
<span class="ann">// static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다</span>
@@ -1009,8 +1018,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="kw">private void</span> <span class="fn">Start</span>()
{
<span class="ann">// FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출)</span>
_audio = <span class="fn">FindObjectOfType</span>&lt;<span class="ty">AudioManager</span>&gt;();
<span class="ann">// FindFirstObjectByType: Unity 6 기준 FindObjectOfType 대체 API</span>
_audio = <span class="fn">FindFirstObjectByType</span>&lt;<span class="ty">AudioManager</span>&gt;();
<span class="fn">StartCoroutine</span>(<span class="fn">LoadAndPlay</span>());
}
@@ -1047,8 +1056,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="ann">// File.ReadAllText + JsonUtility.FromJson = 동기 파싱. 파일이 작으므로 문제없음</span>
<span class="ty">MapData</span> map = JsonUtility.<span class="fn">FromJson</span>&lt;<span class="ty">MapData</span>&gt;(File.<span class="fn">ReadAllText</span>(mapPath));
<span class="kw">if</span> (map?.target == <span class="kw">null</span>) { Debug.<span class="fn">LogError</span>(<span class="st">"Map parse failed"</span>); <span class="kw">yield break</span>; }
<span class="ann">// time 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리</span>
map.target.<span class="fn">Sort</span>((a, b) =&gt; a.time.<span class="fn">CompareTo</span>(b.time));
<span class="ann">// time → position → lineLayer 순 정렬. 같은 시간대 노트도 처리 순서가 안정적이다</span>
map.target.<span class="fn">Sort</span>(<span class="fn">CompareNotes</span>);
<span class="ann">// ── 카운트다운 → 음악 시작 → 스폰 루프 ──────────────────</span>
<span class="kw">yield return</span> <span class="fn">StartCoroutine</span>(<span class="fn">Countdown</span>());
@@ -1096,11 +1105,10 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="kw">private void</span> <span class="fn">SpawnNote</span>(<span class="ty">NoteData</span> note)
{
<span class="ann">// Beat Saber 4열 그리드 → 월드 X 좌표 선형 매핑</span>
<span class="ann">// 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭)</span>
<span class="kw">float</span> x = <span class="nm">-0.375f</span> + note.position * <span class="nm">0.25f</span>;
<span class="ann">// 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위)</span>
<span class="kw">float</span> y = <span class="nm">-0.333f</span> + note.lineLayer * <span class="nm">0.333f</span>;
<span class="ann">// Beat Saber 4열/3행 그리드 → VRBeatsKit 상대 좌표</span>
<span class="ann">// 현재 X: -0.63, -0.21, 0.21, 0.63. 인접 큐브의 가로 겹침을 피하기 위한 폭</span>
<span class="kw">float</span> x = <span class="fn">MapLaneX</span>(note.position);
<span class="kw">float</span> y = <span class="fn">MapLayerY</span>(note.lineLayer);
<span class="ann">// ★ 핵심: travelTimeOverride 계산</span>
<span class="ann">// 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남</span>
@@ -1125,6 +1133,28 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="ty">VR_BeatManager</span>.instance.<span class="fn">Spawn</span>(cubePrefab, info);
}
<span class="ann">// 같은 time을 가진 노트가 많아도 정렬 결과가 매번 같도록 비교 기준을 명시</span>
<span class="kw">private static int</span> <span class="fn">CompareNotes</span>(<span class="ty">NoteData</span> a, <span class="ty">NoteData</span> b)
{
<span class="kw">int</span> timeCompare = a.time.<span class="fn">CompareTo</span>(b.time);
<span class="kw">if</span> (timeCompare != <span class="nm">0</span>) <span class="kw">return</span> timeCompare;
<span class="kw">int</span> positionCompare = a.position.<span class="fn">CompareTo</span>(b.position);
<span class="kw">if</span> (positionCompare != <span class="nm">0</span>) <span class="kw">return</span> positionCompare;
<span class="kw">return</span> a.lineLayer.<span class="fn">CompareTo</span>(b.lineLayer);
}
<span class="kw">private static float</span> <span class="fn">MapLaneX</span>(<span class="kw">int</span> position)
{
<span class="kw">int</span> lane = Mathf.<span class="fn">Clamp</span>(position, <span class="nm">0</span>, <span class="nm">3</span>);
<span class="kw">return</span> (lane - HorizontalCenter) * LaneSpacing;
}
<span class="kw">private static float</span> <span class="fn">MapLayerY</span>(<span class="kw">int</span> lineLayer)
{
<span class="kw">int</span> layer = Mathf.<span class="fn">Clamp</span>(lineLayer, <span class="nm">0</span>, <span class="nm">2</span>);
<span class="kw">return</span> (layer - VerticalCenter) * LayerSpacing;
}
<span class="ann">// ── cutDirection 조회 테이블 ──────────────────────────────</span>
<span class="ann">// Beat Saber 숫자(0-8) → VRBeatsKit Direction enum</span>
<span class="ann">// if-else 8개 대신 배열 인덱스로 O(1) 변환. static readonly = 앱 수명 동안 1번만 할당</span>
@@ -1160,7 +1190,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<div id="p-audiomgr" class="panel">
<div class="file-header">
<h1>AudioManager.cs <span style="font-size:14px;font-weight:400;color:var(--mu)">(VRBeatsKit)</span></h1>
<p>VRBeatsKit 내장 오디오 관리자. 우리 코드에서 <code>PlayClip()</code> <code>CurrentTime</code>만 추가했다.</p>
<p>VRBeatsKit 내장 오디오 관리자. <code>PlayScheduled()</code> <code>AudioSettings.dspTime</code> 기준 시간을 사용해 음악/노트 싱크 흔들림을 줄인다.</p>
</div>
<div class="cw"><div class="ch"><span>VRBeatsKit/Scripts/Core/AudioManager.cs</span></div><pre>
<span class="kw">namespace</span> <span class="ty">VRBeats</span>
@@ -1174,6 +1204,8 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
[<span class="ty">SerializeField</span>] <span class="kw">private float</span> fadeOutTime = <span class="nm">4.0f</span>; <span class="ann">// 피치 페이드 시간</span>
<span class="kw">private</span> <span class="ty">AudioSource</span> audioSource = <span class="kw">null</span>;
<span class="kw">private double</span> scheduledDspStartTime = -<span class="nm">1.0</span>;
<span class="kw">private bool</span> hasScheduledClip = <span class="kw">false</span>;
<span class="kw">private void</span> <span class="fn">Start</span>()
{
@@ -1212,17 +1244,40 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="kw">public void</span> <span class="fn">SetAudioMixerPitch</span>(<span class="kw">float</span> value)
=&gt; audioSource.outputAudioMixerGroup.audioMixer.<span class="fn">SetFloat</span>(<span class="st">"Pitch"</span>, value);
<span class="ann">// ★ 우리가 추가한 메서드 ─────────────────────────────────</span>
<span class="ann">// SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출</span>
<span class="ann">// 내부적으로 예약 재생을 사용해 프레임 상태와 무관한 DSP 기준 시작 시간을 만든다</span>
<span class="kw">public void</span> <span class="fn">PlayClip</span>(<span class="ty">AudioClip</span> clip)
{
audioSource.clip = clip; <span class="ann">// 재생할 클립 교체</span>
audioSource.<span class="fn">Play</span>(); <span class="ann">// 즉시 재생 시작</span>
<span class="fn">PlayClipScheduled</span>(clip);
}
<span class="ann">// 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌</span>
<span class="ann">// audioSource가 null이면 0 반환 (씬 초기화 중 안전)</span>
<span class="kw">public float</span> CurrentTime =&gt; audioSource != <span class="kw">null</span> ? audioSource.time : <span class="nm">0f</span>;
<span class="ann">// AudioSource.Play()는 호출 프레임과 오디오 스레드 타이밍에 따라 시작 오차가 생길 수 있</span>
<span class="ann">// PlayScheduled는 DSP 시간에 맞춰 재생 예약하므로 노트 스폰 기준을 더 안정적으로 잡을 수 있다</span>
<span class="kw">public double</span> <span class="fn">PlayClipScheduled</span>(<span class="ty">AudioClip</span> clip, <span class="kw">double</span> delaySeconds = <span class="nm">0.1</span>)
{
<span class="fn">ResetThisComponent</span>();
audioSource.<span class="fn">Stop</span>();
audioSource.clip = clip;
audioSource.time = <span class="nm">0.0f</span>;
scheduledDspStartTime = <span class="ty">AudioSettings</span>.dspTime + delaySeconds;
hasScheduledClip = <span class="kw">true</span>;
audioSource.<span class="fn">PlayScheduled</span>(scheduledDspStartTime);
<span class="kw">return</span> scheduledDspStartTime;
}
<span class="ann">// 현재 재생 위치(초). 예약 재생 중이면 AudioSource.time 대신 DSP 시간 차이를 사용한다</span>
<span class="ann">// 예약 시작 전에는 음수일 수 있으나 SpawnRoutine의 spawnAt은 0 이상이라 조기 스폰되지 않는다</span>
<span class="kw">public float</span> CurrentTime
{
<span class="kw">get</span>
{
<span class="kw">if</span> (audioSource == <span class="kw">null</span>) <span class="kw">return</span> <span class="nm">0.0f</span>;
<span class="kw">if</span> (hasScheduledClip) <span class="kw">return</span> (<span class="kw">float</span>)(<span class="ty">AudioSettings</span>.dspTime - scheduledDspStartTime);
<span class="kw">return</span> audioSource.time;
}
}
}
}
</pre></div>
@@ -1777,7 +1832,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="kw">if</span> (_cardFont != <span class="kw">null</span>) tTmp.font = _cardFont;
tTmp.text = song.title; tTmp.fontSize = <span class="nm">5f</span>; tTmp.color = <span class="ty">Color</span>.white;
tTmp.overflowMode = <span class="ty">TextOverflowModes</span>.Overflow; <span class="ann">// 영역 넘어도 잘리지 않음 (RectMask2D가 처리)</span>
tTmp.enableWordWrapping = <span class="kw">false</span>; <span class="ann">// 줄바꿈 금지 → 한 줄로</span>
tTmp.textWrappingMode = <span class="ty">TextWrappingModes</span>.NoWrap; <span class="ann">// Unity 6/TMP 최신 API. 줄바꿈 금지</span>
titleGO.<span class="fn">AddComponent</span>&lt;<span class="ty">MarqueeText</span>&gt;(); <span class="ann">// 텍스트가 컨테이너보다 길면 자동 스크롤</span>
<span class="ann">// ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사</span>
@@ -1988,6 +2043,129 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
</pre></div>
</div>
<!-- ══════════════════════ VRPointerController.cs ══════════════════════ -->
<div id="p-vrpointer" class="panel">
<div class="file-header">
<h1>VRPointerController.cs</h1>
<p>VR 컨트롤러에서 직접 World Space UI 버튼을 hover/click 처리하는 런타임 포인터. 게임오버 Back/Retry와 SongCreator UI 클릭 안정화용.</p>
</div>
<div class="box box-g"><div class="lbl">핵심 의도</div><p>XR UI 모듈의 씬별 설정에 의존하지 않고, 컨트롤러 forward 레이와 Unity UI <code>Selectable</code>을 직접 교차 검사한다. 클릭은 <code>ExecuteEvents</code><code>Button.onClick.Invoke()</code>를 함께 호출한다.</p></div>
<div class="cw"><div class="ch"><span>VRPointerController.cs</span></div><pre>
[<span class="ty">RequireComponent</span>(<span class="kw">typeof</span>(<span class="ty">LineRenderer</span>))]
<span class="kw">public class</span> <span class="ty">VRPointerController</span> : <span class="ty">MonoBehaviour</span>
{
[<span class="ty">SerializeField</span>] <span class="kw">private bool</span> isRightHand = <span class="kw">true</span>;
[<span class="ty">SerializeField</span>] <span class="kw">private float</span> maxDistance = <span class="nm">50f</span>;
<span class="kw">private</span> <span class="ty">LineRenderer</span> _line;
<span class="kw">private</span> <span class="ty">Selectable</span> _currentHover;
<span class="kw">private bool</span> _prevTrigger, _prevPrimary;
<span class="kw">private void</span> <span class="fn">Awake</span>()
{
_line = <span class="fn">GetComponent</span>&lt;<span class="ty">LineRenderer</span>&gt;();
_line.positionCount = <span class="nm">2</span>;
_line.startWidth = <span class="nm">0.005f</span>;
_line.endWidth = <span class="nm">0.001f</span>;
_line.useWorldSpace = <span class="kw">true</span>;
}
<span class="kw">private void</span> <span class="fn">Update</span>()
{
<span class="kw">bool</span> trigger = <span class="fn">GetButton</span>(<span class="ty">CommonUsages</span>.triggerButton);
<span class="kw">bool</span> primary = <span class="fn">GetButton</span>(<span class="ty">CommonUsages</span>.primaryButton);
<span class="kw">bool</span> triggerDown = trigger &amp;&amp; !_prevTrigger;
<span class="kw">bool</span> primaryDown = primary &amp;&amp; !_prevPrimary;
_prevTrigger = trigger;
_prevPrimary = primary;
<span class="kw">var</span> ray = <span class="kw">new</span> <span class="ty">Ray</span>(transform.position, transform.forward);
<span class="kw">float</span> hitDist = maxDistance;
<span class="ty">Selectable</span> hit = <span class="fn">FindSelectableUnderRay</span>(ray, <span class="kw">ref</span> hitDist);
<span class="fn">UpdateHoverState</span>(hit);
<span class="ann">// 검지 트리거 또는 A/X 버튼으로 현재 hover된 Selectable 클릭</span>
<span class="kw">if</span> ((triggerDown || primaryDown) &amp;&amp; _currentHover != <span class="kw">null</span>)
<span class="fn">Click</span>(_currentHover);
<span class="fn">DrawLine</span>(hitDist);
}
<span class="kw">private static void</span> <span class="fn">Click</span>(<span class="ty">Selectable</span> sel)
{
<span class="kw">var</span> es = <span class="ty">EventSystem</span>.current;
<span class="kw">if</span> (es == <span class="kw">null</span>) <span class="kw">return</span>;
<span class="kw">var</span> eventData = <span class="kw">new</span> <span class="ty">PointerEventData</span>(es);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerDownHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerUpHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerClickHandler);
<span class="ann">// 일부 Button은 ExecuteEvents만으로 onClick까지 가지 않는 경우가 있어 명시 호출</span>
<span class="kw">var</span> btn = sel.<span class="fn">GetComponent</span>&lt;<span class="ty">Button</span>&gt;();
<span class="kw">if</span> (btn != <span class="kw">null</span>) btn.onClick.<span class="fn">Invoke</span>();
}
<span class="ann">// Selectable의 RectTransform 월드 코너와 레이/평면 교차를 직접 계산</span>
<span class="kw">private static</span> <span class="ty">Selectable</span> <span class="fn">FindSelectableUnderRay</span>(<span class="ty">Ray</span> ray, <span class="kw">ref float</span> maxDist) { ... }
<span class="kw">private bool</span> <span class="fn">GetButton</span>(<span class="ty">InputFeatureUsage</span>&lt;<span class="kw">bool</span>&gt; usage) { ... }
}
</pre></div>
</div>
<!-- ══════════════════════ VRPointerSetup.cs ══════════════════════ -->
<div id="p-vrptrsetup" class="panel">
<div class="file-header">
<h1>VRPointerSetup.cs</h1>
<p>모든 씬에서 자동 실행되는 포인터 주입기. 컨트롤러/손 오브젝트를 찾아 <code>VRPointerController</code>를 붙인다.</p>
</div>
<div class="cw"><div class="ch"><span>VRPointerSetup.cs</span></div><pre>
<span class="kw">public class</span> <span class="ty">VRPointerSetup</span> : <span class="ty">MonoBehaviour</span>
{
<span class="kw">private static</span> <span class="ty">VRPointerSetup</span> instance;
[<span class="ty">RuntimeInitializeOnLoadMethod</span>(<span class="ty">RuntimeInitializeLoadType</span>.BeforeSceneLoad)]
<span class="kw">private static void</span> <span class="fn">AutoInject</span>()
{
<span class="kw">if</span> (instance != <span class="kw">null</span>) <span class="kw">return</span>;
<span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"[VRPointerSetup]"</span>).<span class="fn">AddComponent</span>&lt;<span class="ty">VRPointerSetup</span>&gt;();
}
<span class="kw">private void</span> <span class="fn">Awake</span>()
{
<span class="kw">if</span> (instance != <span class="kw">null</span> &amp;&amp; instance != <span class="kw">this</span>) { <span class="fn">Destroy</span>(gameObject); <span class="kw">return</span>; }
instance = <span class="kw">this</span>;
<span class="ty">DontDestroyOnLoad</span>(gameObject);
}
<span class="kw">private void</span> <span class="fn">OnEnable</span>() =&gt; <span class="ty">SceneManager</span>.sceneLoaded += <span class="fn">OnSceneLoaded</span>;
<span class="kw">private void</span> <span class="fn">OnDisable</span>() =&gt; <span class="ty">SceneManager</span>.sceneLoaded -= <span class="fn">OnSceneLoaded</span>;
<span class="kw">private static void</span> <span class="fn">SetupScene</span>(<span class="ty">Scene</span> scene)
{
<span class="kw">bool</span> isGameScene = scene.name == <span class="st">"Game"</span>;
<span class="fn">SetupControllers</span>(disabledByDefault: isGameScene);
}
<span class="ann">// Game 씬에서는 게임오버 전까지 포인터 비활성. VR_InteractorController가 GameOver 때 켠다</span>
<span class="kw">private static void</span> <span class="fn">SetupControllers</span>(<span class="kw">bool</span> disabledByDefault)
{
<span class="kw">foreach</span> (<span class="kw">var</span> go <span class="kw">in</span> <span class="fn">FindObjectsByType</span>&lt;<span class="ty">GameObject</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None))
{
<span class="kw">bool</span> isRight = go.name.<span class="fn">Contains</span>(<span class="st">"Right"</span>);
<span class="kw">bool</span> isLeft = go.name.<span class="fn">Contains</span>(<span class="st">"Left"</span>);
<span class="kw">if</span> (!isRight &amp;&amp; !isLeft) <span class="kw">continue</span>;
<span class="kw">if</span> (go.<span class="fn">GetComponent</span>&lt;<span class="ty">LineRenderer</span>&gt;() == <span class="kw">null</span>) <span class="kw">continue</span>;
<span class="kw">if</span> (go.<span class="fn">GetComponent</span>&lt;<span class="ty">VRPointerController</span>&gt;() != <span class="kw">null</span>) <span class="kw">continue</span>;
<span class="kw">var</span> pointer = go.<span class="fn">AddComponent</span>&lt;<span class="ty">VRPointerController</span>&gt;();
<span class="kw">if</span> (disabledByDefault) pointer.enabled = <span class="kw">false</span>;
}
}
}
</pre></div>
</div>
<!-- ══════════════════════ DesktopUIMode.cs ══════════════════════ -->
<div id="p-desktop" class="panel">
<div class="file-header">
@@ -2008,7 +2186,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
[<span class="ty">RuntimeInitializeOnLoadMethod</span>(<span class="ty">RuntimeInitializeLoadType</span>.AfterSceneLoad)]
<span class="kw">private static void</span> <span class="fn">AutoCreate</span>()
{
<span class="kw">if</span> (<span class="fn">FindObjectOfType</span>&lt;<span class="ty">DesktopUIMode</span>&gt;() != <span class="kw">null</span>) <span class="kw">return</span>; <span class="ann">// 이미 있으면 스킵</span>
<span class="kw">if</span> (<span class="fn">FindFirstObjectByType</span>&lt;<span class="ty">DesktopUIMode</span>&gt;() != <span class="kw">null</span>) <span class="kw">return</span>; <span class="ann">// 이미 있으면 스킵</span>
<span class="kw">new</span> <span class="ty">GameObject</span>(<span class="st">"[DesktopUIMode]"</span>).<span class="fn">AddComponent</span>&lt;<span class="ty">DesktopUIMode</span>&gt;();
}
@@ -2073,7 +2251,7 @@ h4{font-size:12px;font-weight:700;color:var(--mu);text-transform:uppercase;lette
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="ann">// main 카메라 없으면 활성 카메라 중 첫 번째</span>
<span class="kw">foreach</span> (<span class="kw">var</span> c <span class="kw">in</span> <span class="fn">FindObjectsByType</span>&lt;<span class="ty">Camera</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None))
<span class="kw">if</span> (c.enabled &amp;&amp; c.gameObject.scene.name != <span class="st">"DontDestroyOnLoad"</span>) { cam = c; <span class="kw">break</span>; }
cam ??= <span class="fn">FindObjectOfType</span>&lt;<span class="ty">Camera</span>&gt;(); <span class="ann">// 최후의 수단</span>
cam ??= <span class="fn">FindFirstObjectByType</span>&lt;<span class="ty">Camera</span>&gt;(); <span class="ann">// 최후의 수단</span>
<span class="kw">if</span> (cam == <span class="kw">null</span>) <span class="kw">return</span>;
<span class="kw">foreach</span> (<span class="kw">var</span> canvas <span class="kw">in</span> <span class="fn">FindObjectsByType</span>&lt;<span class="ty">Canvas</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None))
+145 -14
View File
@@ -316,6 +316,7 @@
<a href="#naspublisher">NasPublisher.cs</a>
<a href="#downloadmanager">DownloadManager.cs</a>
<a href="#songcontroller">SongController.cs</a>
<a href="#vrpointer">VR UI 포인터</a>
<div class="section-label">UI</div>
<a href="#songselectmanager">SongSelectManager.cs</a>
@@ -333,13 +334,14 @@
<!-- 헤더 -->
<div class="page-header">
<h1>VR Beat Saber — 코드 리뷰</h1>
<p>Unity + Meta Quest 기반 커스텀 비트 세이버 클론 | 학습용 코드 리뷰 문서</p>
<p>Unity 6000.3.12f1 + Meta Quest 기반 커스텀 비트 세이버 클론 | 최신 코드 리뷰 문서</p>
<div style="margin-top:12px">
<span class="badge badge-blue">Unity 2022+</span>
<span class="badge badge-blue">Unity 6000.3.12f1</span>
<span class="badge badge-green">C#</span>
<span class="badge badge-purple">VRBeatsKit</span>
<span class="badge badge-yellow">Beat Sage API</span>
<span class="badge badge-red">Synology NAS</span>
<span class="badge badge-green">Build: 경고 0 / 오류 0</span>
</div>
</div>
@@ -359,7 +361,7 @@
</div>
<div class="card">
<h4>Game 씬</h4>
<p>캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.</p>
<p>캐시된 MP3 + 맵 JSON 로드 → DSP 기준 오디오 재생 → 큐브 스폰 → 게임오버/결과 UI.</p>
</div>
</div>
</section>
@@ -416,11 +418,13 @@ Application.temporaryCachePath/beatsaber/
<h3>스크립트 의존 관계</h3>
<table>
<tr><th>스크립트</th><th>의존 대상</th><th>의존 방식</th></tr>
<tr><td>SongController</td><td>GameSession, AudioManager, VR_BeatManager</td><td>static / FindObjectOfType / singleton</td></tr>
<tr><td>SongController</td><td>GameSession, AudioManager, VR_BeatManager</td><td>static / FindFirstObjectByType / singleton</td></tr>
<tr><td>SongSelectManager</td><td>DownloadManager, SongDetailPanel, SongLibrary</td><td>SerializeField / singleton</td></tr>
<tr><td>NasPublisher</td><td>BeatSageConverter</td><td>static class 직접 호출</td></tr>
<tr><td>BeatSageUploader</td><td>BeatSageConverter, NoteData</td><td>static class 직접 호출</td></tr>
<tr><td>DownloadManager</td><td>NoteData (SongInfo)</td><td>파라미터</td></tr>
<tr><td>VRPointerSetup</td><td>VRPointerController, SceneManager</td><td>RuntimeInitializeOnLoadMethod / sceneLoaded</td></tr>
<tr><td>VRPointerController</td><td>Selectable, EventSystem, XR InputDevice</td><td>직접 Ray/Rect 교차 + ExecuteEvents</td></tr>
</table>
</section>
@@ -752,6 +756,38 @@ list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version
}</pre>
</div>
<h3>오디오 싱크 — DSP 기준 예약 재생</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">AudioManager.cs — PlayClipScheduled()</span></div>
<pre><span class="kw">public double</span> <span class="fn">PlayClipScheduled</span>(<span class="ty">AudioClip</span> clip, <span class="kw">double</span> delaySeconds = <span class="num">0.1</span>)
{
audioSource.<span class="fn">Stop</span>();
audioSource.clip = clip;
audioSource.time = <span class="num">0.0f</span>;
scheduledDspStartTime = <span class="ty">AudioSettings</span>.dspTime + delaySeconds;
hasScheduledClip = <span class="kw">true</span>;
audioSource.<span class="fn">PlayScheduled</span>(scheduledDspStartTime);
<span class="kw">return</span> scheduledDspStartTime;
}
<span class="kw">public float</span> CurrentTime
{
<span class="kw">get</span>
{
<span class="kw">if</span> (hasScheduledClip)
<span class="kw">return</span> (<span class="kw">float</span>)(<span class="ty">AudioSettings</span>.dspTime - scheduledDspStartTime);
<span class="kw">return</span> audioSource.time;
}
}</pre>
</div>
<div class="point point-green">
<div class="label">개선 완료</div>
<p><code>AudioSource.Play()</code> 대신 <code>PlayScheduled()</code>를 사용해 음악 시작 시점을 DSP 시간에 고정했다. 노트 스폰 기준도 <code>AudioSettings.dspTime</code> 차이로 계산하므로 프레임 상태에 따른 싱크 흔들림이 줄었다.</p>
</div>
<div class="point point-blue">
<div class="label">학습 포인트 — 타이밍 보정 기법</div>
<p>
@@ -784,9 +820,70 @@ list ??= <span class="kw">new</span> <span class="ty">SongsList</span> { version
<h3>위치 계산</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SongController.cs</span></div>
<pre><span class="cmt">// Beat Saber 그리드 → 월드 좌표 선형 매핑</span>
<span class="ty">float</span> x = <span class="num">-0.375f</span> + note.position * <span class="num">0.25f</span>; <span class="cmt">// 열 0→-0.375, 1→-0.125, 2→0.125, 3→0.375</span>
<span class="ty">float</span> y = <span class="num">-0.333f</span> + note.lineLayer * <span class="num">0.333f</span>; <span class="cmt">// 행 0→-0.333, 1→0, 2→0.333</span></pre>
<pre><span class="kw">private const float</span> LaneSpacing = <span class="num">0.42f</span>;
<span class="kw">private const float</span> LayerSpacing = <span class="num">0.38f</span>;
<span class="kw">private const float</span> HorizontalCenter = <span class="num">1.5f</span>;
<span class="kw">private const float</span> VerticalCenter = <span class="num">1f</span>;
<span class="kw">private static float</span> <span class="fn">MapLaneX</span>(<span class="ty">int</span> position)
{
<span class="ty">int</span> lane = Mathf.<span class="fn">Clamp</span>(position, <span class="num">0</span>, <span class="num">3</span>);
<span class="kw">return</span> (lane - HorizontalCenter) * LaneSpacing;
}
<span class="kw">private static float</span> <span class="fn">MapLayerY</span>(<span class="ty">int</span> lineLayer)
{
<span class="ty">int</span> layer = Mathf.<span class="fn">Clamp</span>(lineLayer, <span class="num">0</span>, <span class="num">2</span>);
<span class="kw">return</span> (layer - VerticalCenter) * LayerSpacing;
}</pre>
</div>
<div class="point point-green">
<div class="label">개선 완료 — 가로 겹침</div>
<p>기존 라인 간격은 <code>0.25</code>였고 큐브 실제 폭은 약 <code>0.36</code>이라 인접 라인이 겹칠 수밖에 없었다. 현재 X 좌표는 대략 <code>-0.63, -0.21, 0.21, 0.63</code>으로 벌어져 가로 겹침을 피한다.</p>
</div>
</section>
<!-- ───────────────────────────────── VR Pointer ── -->
<section id="vrpointer">
<h2>VRPointerController / VRPointerSetup — VR UI 클릭 안정화</h2>
<p>게임오버 Back/Retry와 SongCreator UI 버튼이 컨트롤러로 클릭되지 않던 문제를 해결하기 위해 추가된 런타임 포인터 시스템이다.</p>
<h3>구조</h3>
<div class="flow">
<div class="flow-box">VRPointerSetup<br><small style="color:var(--muted)">BeforeSceneLoad 자동 생성</small></div>
<span class="flow-arrow"></span>
<div class="flow-box">SceneManager.sceneLoaded</div>
<span class="flow-arrow"></span>
<div class="flow-box">Controller/Hand + LineRenderer 탐색</div>
<span class="flow-arrow"></span>
<div class="flow-box highlight">VRPointerController 주입</div>
</div>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">VRPointerController.cs — 클릭 처리</span></div>
<pre><span class="kw">private static void</span> <span class="fn">Click</span>(<span class="ty">Selectable</span> sel)
{
<span class="kw">var</span> es = <span class="ty">EventSystem</span>.current;
<span class="kw">var</span> eventData = <span class="kw">new</span> <span class="ty">PointerEventData</span>(es);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerDownHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerUpHandler);
<span class="ty">ExecuteEvents</span>.<span class="fn">Execute</span>(sel.gameObject, eventData, <span class="ty">ExecuteEvents</span>.pointerClickHandler);
<span class="kw">var</span> btn = sel.<span class="fn">GetComponent</span>&lt;<span class="ty">Button</span>&gt;();
<span class="kw">if</span> (btn != <span class="kw">null</span>) btn.onClick.<span class="fn">Invoke</span>();
}</pre>
</div>
<div class="point point-blue">
<div class="label">평가</div>
<p>XR UI 모듈 설정에 의존하지 않고 Selectable을 직접 Ray/Rect 교차 검사하는 방식이라 씬별 Raycaster 설정 차이에 강하다. 단, 커스텀 Selectable이나 비사각형 UI가 늘어나면 GraphicRaycaster 기반으로 재검토할 수 있다.</p>
</div>
<div class="point point-yellow">
<div class="label">실기 확인 필요</div>
<p>Game 씬에서는 포인터가 기본 비활성이고 게임오버 시 <code>VR_InteractorController</code>를 통해 활성화된다. Quest에서 Back/Retry, SongCreator 생성 버튼, 곡 선택 카드 클릭은 직접 확인해야 한다.</p>
</div>
</section>
@@ -875,6 +972,16 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
<td>SongController.CutDirMap, BeatSageUploader.DiffNames</td>
<td><p>static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음.</p></td>
</tr>
<tr>
<td>Deterministic Sort</td>
<td>SongController.CompareNotes()</td>
<td><p>time이 같은 노트도 position, lineLayer 순으로 정렬해 스폰 처리 순서를 안정화.</p></td>
</tr>
<tr>
<td>Runtime Injection</td>
<td>VRPointerSetup</td>
<td><p>씬에 직접 배치하지 않고 런타임에 컨트롤러 오브젝트를 찾아 포인터 컴포넌트를 주입.</p></td>
</tr>
<tr>
<td>Upsert</td>
<td>NasPublisher.PatchSongsJson()</td>
@@ -938,7 +1045,26 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
<span class="kw">using</span> (<span class="kw">var</span> req = <span class="ty">UnityWebRequest</span>.<span class="fn">Get</span>(url)) { ... }</pre>
</div>
<h3>5. Unity ?? 연산자 주의사항</h3>
<h3>5. Unity 6 API 전환</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">deprecated API 정리</span></div>
<pre><span class="cmt">// 이전</span>
FindObjectOfType&lt;<span class="ty">AudioManager</span>&gt;();
FindObjectsOfType&lt;<span class="ty">Canvas</span>&gt;();
tTmp.enableWordWrapping = <span class="kw">false</span>;
<span class="cmt">// 현재</span>
FindFirstObjectByType&lt;<span class="ty">AudioManager</span>&gt;();
FindObjectsByType&lt;<span class="ty">Canvas</span>&gt;(<span class="ty">FindObjectsSortMode</span>.None);
tTmp.textWrappingMode = <span class="ty">TextWrappingModes</span>.NoWrap;</pre>
</div>
<div class="point point-green">
<div class="label">현재 상태</div>
<p><code>dotnet build VRBeatSaber.slnx --no-incremental</code> 기준 경고 0개, 오류 0개다. VRBeatsKit 내부 deprecated API와 미사용 필드 경고까지 정리되어 있다.</p>
</div>
<h3>6. Unity ?? 연산자 주의사항</h3>
<div class="code-wrapper">
<div class="code-header"><span class="code-filename">SaberGlow.cs 버그 사례</span></div>
<pre><span class="cmt">// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함</span>
@@ -1010,12 +1136,17 @@ btn.onClick.<span class="fn">AddListener</span>(() => <span class="fn">OnCardCli
<li class="done">SongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인</li>
<li class="done">SongSelect 씬 — 카드 목록 + 다운로드 + 플레이</li>
<li class="done">Game 씬 — SongController, 카운트다운, 큐브 스폰</li>
<li class="done">travelTimeOverride — 동시 노트 보정</li>
<li class="done">Git remote 설정 (Synology NAS)</li>
<li class="todo">Game 씬 ScoreManager / ScoreHUD 연결</li>
<li class="todo">Game 씬 ResultsPanel 연결 (CLEAR/FAILED, 랭크)</li>
<li class="todo">VR 기기 실제 플레이 테스트</li>
<li class="todo">targetTravelTime 1.8 플레이 후 미세 조정</li>
<li class="done">travelTimeOverride — 동시 노트 도착 타이밍 보정</li>
<li class="done">AudioManager — DSP 기준 PlayScheduled 싱크 개선</li>
<li class="done">VRPointerController/Setup — VR UI hover/click 처리</li>
<li class="done">GameOver Back/Retry 버튼 스크립트 참조 복구</li>
<li class="done">큐브 가로 간격 보정 — 인접 라인 겹침 방지</li>
<li class="done">C# 빌드 경고 0개 정리</li>
<li class="done">Git remote 설정 및 master/main 최신화</li>
<li class="todo">Quest 실기에서 GameOver Back/Retry 클릭 확인</li>
<li class="todo">Quest 실기에서 SongCreator UI 클릭 확인</li>
<li class="todo">큐브 간격, 세이버 각도, targetTravelTime 1.8 체감 조정</li>
<li class="todo">SongCreator 생성 직후 첫 재생 싱크/로드 로그 추가 검증</li>
</ul>
</section>
+55
View File
@@ -0,0 +1,55 @@
# VRBeats Unity MCP Server
This MCP server connects Codex to the Unity Editor bridge in this project.
## Unity side
Open the project in Unity. The bridge auto-starts on domain reload and listens only on:
```text
http://127.0.0.1:19744
```
Manual controls are available in Unity:
```text
Tools > Codex Bridge > Start Server
Tools > Codex Bridge > Stop Server
Tools > Codex Bridge > Auto Start
Tools > Codex Bridge > Capture Game View
```
## Install
Run once from this folder:
```powershell
npm install
```
## Codex MCP config
Add a server entry like this to the Codex MCP config, then restart Codex/VS Code so the tool list refreshes:
```toml
[mcp_servers.vrbeats_unity]
command = "node"
args = ["C:\\Users\\User-40\\Desktop\\unity\\work\\BeatSabar\\VRBeatSaber\\tools\\unity-mcp-server\\index.mjs"]
startup_timeout_sec = 30
[mcp_servers.vrbeats_unity.env]
UNITY_BRIDGE_URL = "http://127.0.0.1:19744"
```
## Exposed tools
- `unity_health`
- `unity_capture_game_view`
- `unity_get_console_logs`
- `unity_list_scene_roots`
- `unity_list_scene_objects`
- `unity_get_object`
- `unity_set_transform`
- `unity_set_play_state`
`unity_capture_game_view` writes `Captures/latest.png` in the project root.
@@ -0,0 +1,7 @@
[mcp_servers.vrbeats_unity]
command = "node"
args = ["C:\\Users\\User-40\\Desktop\\unity\\work\\BeatSabar\\VRBeatSaber\\tools\\unity-mcp-server\\index.mjs"]
startup_timeout_sec = 30
[mcp_servers.vrbeats_unity.env]
UNITY_BRIDGE_URL = "http://127.0.0.1:19744"
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
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",
version: "0.1.0",
});
function textResult(value) {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return {
content: [
{
type: "text",
text,
},
],
};
}
async function callUnity(path, options = {}) {
const response = await fetchUnity(path, options);
const rawText = await response.text();
let parsed;
try {
parsed = rawText.length > 0 ? JSON.parse(rawText) : {};
} catch {
parsed = { ok: false, rawText };
}
if (!response.ok) {
const detail = typeof parsed === "object" ? JSON.stringify(parsed, null, 2) : rawText;
throw new Error(`Unity bridge HTTP ${response.status}: ${detail}`);
}
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();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
query.set(key, String(value));
}
}
const value = query.toString();
return value.length > 0 ? `?${value}` : "";
}
const Vector3Schema = z.object({
x: z.number(),
y: z.number(),
z: z.number(),
});
server.registerTool(
"unity_health",
{
title: "Unity Bridge Health",
description: "Check whether the Unity Editor bridge is reachable and report play state.",
inputSchema: {},
},
async () => textResult(await callUnity("/health")),
);
server.registerTool(
"unity_capture_game_view",
{
title: "Capture Unity Game View",
description: "Capture the active Unity camera to Captures/latest.png and return the local file path.",
inputSchema: {
width: z.number().int().min(320).max(4096).default(1280),
height: z.number().int().min(180).max(4096).default(720),
},
},
async ({ width = 1280, height = 720 }) => {
const result = await callUnity(`/capture${queryString({ width, height })}`);
return textResult(result);
},
);
server.registerTool(
"unity_get_console_logs",
{
title: "Get Unity Console Logs",
description: "Read recent logs captured by the Unity Editor bridge.",
inputSchema: {
count: z.number().int().min(1).max(250).default(80),
},
},
async ({ count = 80 }) => textResult(await callUnity(`/logs${queryString({ count })}`)),
);
server.registerTool(
"unity_list_scene_roots",
{
title: "List Unity Scene Roots",
description: "List root GameObjects in the active Unity scene.",
inputSchema: {},
},
async () => textResult(await callUnity("/scene/roots")),
);
server.registerTool(
"unity_list_scene_objects",
{
title: "List Unity Scene Objects",
description: "List GameObjects in the active Unity scene, optionally filtered by hierarchy path text.",
inputSchema: {
query: z.string().optional().describe("Case-insensitive path filter."),
limit: z.number().int().min(1).max(500).default(120),
},
},
async ({ query = "", limit = 120 }) =>
textResult(await callUnity(`/scene/objects${queryString({ query, limit })}`)),
);
server.registerTool(
"unity_get_object",
{
title: "Get Unity Object",
description: "Read transform and component information for a GameObject by hierarchy path.",
inputSchema: {
path: z.string().min(1).describe("Hierarchy path, for example Canvas/Panel/Button."),
},
},
async ({ path }) => textResult(await callUnity(`/object${queryString({ path })}`)),
);
server.registerTool(
"unity_set_transform",
{
title: "Set Unity Object Transform",
description: "Set world/local position, rotation, or local scale for a GameObject by hierarchy path.",
inputSchema: {
path: z.string().min(1),
position: Vector3Schema.optional(),
localPosition: Vector3Schema.optional(),
rotationEuler: Vector3Schema.optional(),
localRotationEuler: Vector3Schema.optional(),
scale: Vector3Schema.optional(),
},
},
async (input) => textResult(await callUnity("/transform", { method: "POST", body: input })),
);
server.registerTool(
"unity_set_play_state",
{
title: "Set Unity Play State",
description: "Start, pause, resume, or stop Unity Play Mode.",
inputSchema: {
state: z.enum(["play", "pause", "resume", "stop"]),
},
},
async ({ state }) => {
const endpoint = state === "stop" ? "/stop" : state === "pause" ? "/pause" : "/play";
return textResult(await callUnity(endpoint));
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "vrbeats-unity-mcp-server",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"vrbeats-unity-mcp": "./index.mjs"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.25.0"
},
"scripts": {
"start": "node ./index.mjs"
}
}