feat: polish VR gameplay and sync tools

This commit is contained in:
jongjae0305
2026-05-28 19:01:20 +09:00
parent ee34d79a66
commit 03105a4f85
50 changed files with 4986 additions and 328 deletions
+16
View File
@@ -20,5 +20,21 @@
# Credentials — never commit # Credentials — never commit
.env .env
/env
/cookies.txt
/Assets/StreamingAssets/nas_config.json /Assets/StreamingAssets/nas_config.json
/Assets/StreamingAssets/nas_config.json.meta /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
+910
View File
@@ -0,0 +1,910 @@
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 Port = 19744;
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 _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 = new TcpListener(IPAddress.Loopback, Port);
_listener.Server.ExclusiveAddressUse = true;
_listener.Start();
_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 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:
+88 -22
View File
@@ -587,13 +587,18 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: editorPart.handSelected propertyPath: editorPart.handSelected
value: 1 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: editorPart.selectedMenu propertyPath: editorPart.selectedMenu
value: 1 value: 1
objectReference: {fileID: 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, - target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: colorSide propertyPath: colorSide
@@ -627,12 +632,12 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.w propertyPath: m_LocalRotation.w
value: 0.9239 value: 0.7071068
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.x propertyPath: m_LocalRotation.x
value: 0.3827 value: 0.7071068
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
@@ -647,7 +652,7 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalEulerAnglesHint.x propertyPath: m_LocalEulerAnglesHint.x
value: 45 value: 90
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
@@ -1263,7 +1268,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: -7.5, y: -1.7} m_AnchoredPosition: {x: 5.8, y: 2.4}
m_SizeDelta: {x: 847.5, y: 1141.086} m_SizeDelta: {x: 847.5, y: 1141.086}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &454873730 --- !u!114 &454873730
@@ -1284,6 +1289,15 @@ MonoBehaviour:
scoreFollowTime: 1 scoreFollowTime: 1
canvasGroup: {fileID: 454873732} canvasGroup: {fileID: 454873732}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2} onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
comboLabel: {fileID: 0}
accuracyLabel: {fileID: 0}
judgementLabel: {fileID: 0}
createMissingHudLabels: 1
applyHudPlacement: 1
hudAnchoredPosition: {x: 5.8, y: 2.4}
perfectWindow: 0.08
greatWindow: 0.15
goodWindow: 0.25
--- !u!114 &454873731 --- !u!114 &454873731
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -2380,7 +2394,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: m_EditorClassIdentifier:
m_destroyOnLoad: 1 m_destroyOnLoad: 1
currentSDK: 0 currentSDK: 3
gestureConfig: gestureConfig:
minAcelerationThreshold: 15 minAcelerationThreshold: 15
maxAcelerationThreshold: 40 maxAcelerationThreshold: 40
@@ -2652,8 +2666,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5} m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5} m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: -1.4000015, y: 2.5099983} m_AnchoredPosition: {x: 0, y: -0.45}
m_SizeDelta: {x: 20.150002, y: 6.41} m_SizeDelta: {x: 18.8, y: 13.2}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1209841520 --- !u!1 &1209841520
GameObject: GameObject:
@@ -2692,7 +2706,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {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_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1209841522 --- !u!114 &1209841522
@@ -2825,7 +2839,6 @@ MonoBehaviour:
playZone: {fileID: 778115775} playZone: {fileID: 778115775}
player: {fileID: 408071456} player: {fileID: 408071456}
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2} settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
--- !u!4 &1215359035 --- !u!4 &1215359035
Transform: Transform:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -3790,8 +3803,8 @@ MonoBehaviour:
m_faceColor: m_faceColor:
serializedVersion: 2 serializedVersion: 2
rgba: 4294967295 rgba: 4294967295
m_fontSize: 7.1 m_fontSize: 4.4
m_fontSizeBase: 7.1 m_fontSizeBase: 4.4
m_fontWeight: 400 m_fontWeight: 400
m_enableAutoSizing: 0 m_enableAutoSizing: 0
m_fontSizeMin: 18 m_fontSizeMin: 18
@@ -3803,7 +3816,7 @@ MonoBehaviour:
m_characterSpacing: 0 m_characterSpacing: 0
m_characterHorizontalScale: 1 m_characterHorizontalScale: 1
m_wordSpacing: 0 m_wordSpacing: 0
m_lineSpacing: 0 m_lineSpacing: -18
m_lineSpacingMax: 0 m_lineSpacingMax: 0
m_paragraphSpacing: 0 m_paragraphSpacing: 0
m_charWidthMaxAdj: 0 m_charWidthMaxAdj: 0
@@ -4376,6 +4389,7 @@ GameObject:
m_Component: m_Component:
- component: {fileID: 1958381893} - component: {fileID: 1958381893}
- component: {fileID: 1958381892} - component: {fileID: 1958381892}
- component: {fileID: 1958381894}
m_Layer: 0 m_Layer: 0
m_Name: SongController m_Name: SongController
m_TagString: Untagged m_TagString: Untagged
@@ -4414,6 +4428,23 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 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 --- !u!4 &2043306906 stripped
Transform: Transform:
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e, m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
@@ -4457,7 +4488,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {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_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2094521063 --- !u!114 &2094521063
@@ -4857,6 +4888,11 @@ PrefabInstance:
propertyPath: startOnRightController propertyPath: startOnRightController
value: 1 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520, - target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3} type: 3}
propertyPath: editorPart.foldoutInput propertyPath: editorPart.foldoutInput
@@ -4865,13 +4901,23 @@ PrefabInstance:
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520, - target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3} type: 3}
propertyPath: editorPart.handSelected propertyPath: editorPart.handSelected
value: 1 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520, - target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3} type: 3}
propertyPath: editorPart.selectedMenu propertyPath: editorPart.selectedMenu
value: 1 value: 1
objectReference: {fileID: 0} 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, - target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3} type: 3}
propertyPath: m_RootOrder propertyPath: m_RootOrder
@@ -5157,6 +5203,11 @@ PrefabInstance:
propertyPath: startOnRightController propertyPath: startOnRightController
value: 0 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e, - target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3} type: 3}
propertyPath: editorPart.foldoutInput propertyPath: editorPart.foldoutInput
@@ -5165,13 +5216,23 @@ PrefabInstance:
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e, - target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3} type: 3}
propertyPath: editorPart.handSelected propertyPath: editorPart.handSelected
value: 0 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e, - target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3} type: 3}
propertyPath: editorPart.selectedMenu propertyPath: editorPart.selectedMenu
value: 1 value: 1
objectReference: {fileID: 0} 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, - target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3} type: 3}
propertyPath: m_Name propertyPath: m_Name
@@ -5277,7 +5338,7 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: editorPart.handSelected propertyPath: editorPart.handSelected
value: 0 value: 1
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
@@ -5289,6 +5350,11 @@ PrefabInstance:
propertyPath: startOnRightcController propertyPath: startOnRightcController
value: 0 value: 0
objectReference: {fileID: 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, - target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: controller propertyPath: controller
@@ -5317,27 +5383,27 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.w propertyPath: m_LocalRotation.w
value: 0.99958926 value: 0.7071068
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.x propertyPath: m_LocalRotation.x
value: -0 value: 0.7071068
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.y propertyPath: m_LocalRotation.y
value: -0.028659718 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalRotation.z propertyPath: m_LocalRotation.z
value: -0 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
propertyPath: m_LocalEulerAnglesHint.x propertyPath: m_LocalEulerAnglesHint.x
value: 0 value: 90
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c, - target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3} type: 3}
+1 -1
View File
@@ -8107,7 +8107,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3} m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
nasBaseUrl: 'http://whdwo798.synology.me:5000 ' nasBaseUrl: http://whdwo798.synology.me:5000
nasAccount: beatSaber_app nasAccount: beatSaber_app
nasRootPath: /web/beatsaber nasRootPath: /web/beatsaber
staticBaseUrl: http://whdwo798.synology.me/beatsaber staticBaseUrl: http://whdwo798.synology.me/beatsaber
+4 -1
View File
@@ -36,6 +36,8 @@ public class BeatSageNote
public static class BeatSageConverter public static class BeatSageConverter
{ {
private static readonly bool LogConversions = false;
public static List<NoteData> Convert(string rawJson, float bpm) public static List<NoteData> Convert(string rawJson, float bpm)
{ {
var result = new List<NoteData>(); var result = new List<NoteData>();
@@ -62,7 +64,8 @@ public static class BeatSageConverter
}); });
} }
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes."); if (LogConversions)
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
return result; return result;
} }
+53 -2
View File
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
{ {
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber"; [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 ─────────────────────────────────────────── // ── Public API ───────────────────────────────────────────
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
Directory.Delete(dir, recursive: true); Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}"); Debug.Log($"[DownloadManager] 삭제: {songId}");
} }
string legacyDir = LegacySongDir(songId);
if (Directory.Exists(legacyDir))
Directory.Delete(legacyDir, recursive: true);
} }
public void DeleteDifficulty(SongInfo song, string difficulty) public void DeleteDifficulty(SongInfo song, string difficulty)
{ {
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty); string path = MapPath(song, difficulty);
if (path != null && File.Exists(path)) if (path != null && File.Exists(path))
File.Delete(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) public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId)); {
TryMigrateLegacySong(songId);
return File.Exists(AudioPath(songId));
}
public bool IsDifficultyDownloaded(SongInfo song, string difficulty) public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{ {
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty); string path = MapPath(song, difficulty);
return path != null && File.Exists(path); return path != null && File.Exists(path);
} }
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty, private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError) Action<float> onProgress, Action onComplete, Action<string> onError)
{ {
TryMigrateLegacySong(song.id);
string songDir = Path.GetFullPath(SongDir(song.id)); string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir); Directory.CreateDirectory(songDir);
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
private static string SongDir(string songId) private static string SongDir(string songId)
=> Path.Combine(CacheRoot, 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:
+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:
+93 -20
View File
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using UnityEngine; using UnityEngine;
using UnityEngine.Networking; using UnityEngine.Networking;
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
private void LoadConfig() private void LoadConfig()
{ {
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json"); 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)); var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
if (cfg == null) return; if (cfg == null) return;
_password = cfg.password ?? ""; _password = cfg.password ?? "";
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host; if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account; if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath; if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl; if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
NormalizeSettings();
} }
[Serializable] private class NasConfig [Serializable] private class NasConfig
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
Action onComplete, Action onComplete,
Action<string> onError) Action<string> onError)
{ {
NormalizeSettings();
bool failed = false; bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; } void OnErr(string e) { onError?.Invoke(e); failed = true; }
@@ -92,31 +101,53 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError) 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" + string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" + $"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" + $"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" + $"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes"; $"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url); UnityWebRequest req;
yield return req.SendWebRequest(); try
if (req.result != UnityWebRequest.Result.Success)
{ {
onError?.Invoke($"DSM login failed: {req.error}"); req = UnityWebRequest.Get(url);
}
catch (UriFormatException e)
{
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
yield break; yield break;
} }
string resp = req.downloadHandler.text; string resp;
_sid = ParseJsonString(resp, "sid"); using (req)
_synoToken = ParseJsonString(resp, "synotoken"); {
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"DSM login failed: {req.error}");
yield break;
}
resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
}
if (string.IsNullOrEmpty(_sid)) 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() private IEnumerator Logout()
{ {
NormalizeSettings();
string url = $"{nasBaseUrl}/webapi/auth.cgi" + string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}"; $"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url); using var req = UnityWebRequest.Get(url);
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName, private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError) string nasFolder, Action<string> onError)
{ {
NormalizeSettings();
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" + string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" + $"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}"; $"&_sid={UnityWebRequest.EscapeURL(_sid)}";
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError) private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{ {
NormalizeSettings();
SongsList list = null; SongsList list = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json")) using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
private static string ParseJsonString(string json, string key) private static string ParseJsonString(string json, string key)
{ {
string search = $"\"{key}\":\""; if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
int start = json.IndexOf(search, StringComparison.Ordinal); return null;
if (start < 0) return null;
start += search.Length; Match match = Regex.Match(
int end = json.IndexOf('"', start); json,
return end > start ? json.Substring(start, end - start) : null; $"\"{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) private static void AssignMapFile(SongInfo song, string diff, string fileName)
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
var info = song.difficulties.Get(diff); var info = song.difficulties.Get(diff);
if (info != null) info.mapFile = $"maps/{fileName}"; 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) + "...";
}
} }
+23 -7
View File
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
private const float VerticalCenter = 1f; private const float VerticalCenter = 1f;
private AudioManager _audio; private AudioManager _audio;
private ScoreManager _scoreManager;
private float _clipLength;
private static string CacheRoot => private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber"); Path.Combine(Application.persistentDataPath, "beatsaber");
private void Start() private void Start()
{ {
_audio = FindFirstObjectByType<AudioManager>(); _audio = FindFirstObjectByType<AudioManager>();
_scoreManager = FindFirstObjectByType<ScoreManager>();
StartCoroutine(LoadAndPlay()); StartCoroutine(LoadAndPlay());
} }
private void Update()
{
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
}
private IEnumerator LoadAndPlay() private IEnumerator LoadAndPlay()
{ {
SongInfo song = GameSession.SelectedSong; SongInfo song = GameSession.SelectedSong;
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
} }
clip = DownloadHandlerAudioClip.GetContent(req); clip = DownloadHandlerAudioClip.GetContent(req);
} }
_clipLength = clip.length;
// Load and parse map // Load and parse map
DifficultyInfo diffInfo = song.difficulties.Get(diff); DifficultyInfo diffInfo = song.difficulties.Get(diff);
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
yield break; yield break;
} }
map.target.Sort(CompareNotes); map.target.Sort(CompareNotes);
_scoreManager?.SetTotalNotes(map.target.Count);
yield return StartCoroutine(Countdown()); yield return StartCoroutine(Countdown());
_audio.PlayClip(clip); _audio.PlayClip(clip);
StartCoroutine(SpawnRoutine(map.target)); StartCoroutine(SpawnRoutine(map.target));
yield return StartCoroutine(WaitForCompletion(clip.length)); yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
} }
private IEnumerator Countdown() private IEnumerator Countdown()
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
foreach (NoteData note in notes) 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); yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
SpawnNote(note); SpawnNote(note);
} }
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
float y = MapLayerY(note.lineLayer); 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); float travelTime = Mathf.Max(0.05f, remaining);
var info = new SpawnEventInfo var info = new SpawnEventInfo
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
position = new Vector3(x, y, 0f), position = new Vector3(x, y, 0f),
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right, colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
hitDirection = MapCutDirection(note.cutDirection), hitDirection = MapCutDirection(note.cutDirection),
useSpark = true, useSpark = false,
speed = 2f, speed = 2f,
travelTimeOverride = travelTime, travelTimeOverride = travelTime,
}; };
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
private static Direction MapCutDirection(int cut) private static Direction MapCutDirection(int cut)
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center; => (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(); onLevelComplete?.Invoke();
} }
} }
+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:
+258 -24
View File
@@ -11,10 +11,20 @@ namespace VRBeats
{ {
[SerializeField] private bool isRightHand = true; [SerializeField] private bool isRightHand = true;
[SerializeField] private float maxDistance = 50f; [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 LineRenderer _line;
private bool _prevTrigger; private bool _prevTrigger;
private Selectable _currentHover; 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 NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f); private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
@@ -40,7 +50,8 @@ namespace VRBeats
private void Start() private void Start()
{ {
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그 // Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}"); if (debugLogging)
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
} }
private void OnEnable() private void OnEnable()
@@ -56,11 +67,14 @@ namespace VRBeats
private void Update() private void Update()
{ {
// 3초마다 연결된 디바이스 목록 출력 // 3초마다 연결된 디바이스 목록 출력
_deviceLogTimer += Time.deltaTime; if (debugLogging)
if (_deviceLogTimer >= 3f)
{ {
_deviceLogTimer = 0f; _deviceLogTimer += Time.deltaTime;
LogConnectedDevices(); if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
} }
bool trigger = GetButton(CommonUsages.triggerButton); bool trigger = GetButton(CommonUsages.triggerButton);
@@ -70,31 +84,41 @@ namespace VRBeats
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick); bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger; bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip; bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary; bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary; bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick; bool thumbstickDown = thumbstick && !_prevThumbstick;
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
string hand = isRightHand ? "R" : "L"; string hand = isRightHand ? "R" : "L";
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림"); if (debugLogging)
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림"); {
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림"); if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림"); if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (thumbstickDown)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}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward); 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 Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}" ? $"[VRPointer] HOVER → {hit.gameObject.name}"
@@ -103,16 +127,21 @@ namespace VRBeats
UpdateHoverState(hit); UpdateHoverState(hit);
// 검지 트리거 또는 A/X 버튼으로 클릭 if (triggerUp && _dragScrollRect != null)
if (triggerDown || primaryDown) EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{ {
if (_currentHover != null) if (_currentHover != null)
{ {
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X"); string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}"); if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover); Click(_currentHover);
} }
else else if (debugLogging)
{ {
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " + Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}"); $"pos={transform.position:F2} fwd={transform.forward:F2}");
@@ -121,6 +150,12 @@ namespace VRBeats
} }
DrawLine(hitDist); DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
} }
private void LogConnectedDevices() private void LogConnectedDevices()
@@ -222,6 +257,7 @@ namespace VRBeats
foreach (Selectable sel in Selectable.allSelectablesArray) foreach (Selectable sel in Selectable.allSelectablesArray)
{ {
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue; if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>(); var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue; if (rt == null) continue;
@@ -260,6 +296,189 @@ namespace VRBeats
return r >= 0f && r <= 1f && u >= 0f && u <= 1f; 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) private bool GetButton(InputFeatureUsage<bool> usage)
{ {
var chars = InputDeviceCharacteristics.Controller | var chars = InputDeviceCharacteristics.Controller |
@@ -274,5 +493,20 @@ namespace VRBeats
devices[0].TryGetFeatureValue(usage, out bool pressed); devices[0].TryGetFeatureValue(usage, out bool pressed);
return 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 (!isRight && !isLeft) continue;
if (!name.Contains("Controller") && !name.Contains("Hand")) continue; if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
if (go.GetComponent<LineRenderer>() == null) continue; if (go.GetComponent<LineRenderer>() == null) continue;
DisableToolkitPointerComponents(go);
if (go.GetComponent<VRPointerController>() != null) continue; if (go.GetComponent<VRPointerController>() != null) continue;
var pointer = go.AddComponent<VRPointerController>(); var pointer = go.AddComponent<VRPointerController>();
@@ -94,8 +97,18 @@ namespace VRBeats
if (disabledByDefault) if (disabledByDefault)
pointer.enabled = false; 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: balance:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 0 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 --- !u!114 &-7750755424749557576
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 3 m_ObjectHideFlags: 3
@@ -239,19 +217,6 @@ MonoBehaviour:
maxNits: maxNits:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 1000 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 --- !u!114 &-5139089513906902183
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 3 m_ObjectHideFlags: 3
@@ -408,28 +373,6 @@ MonoBehaviour:
tint: tint:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 0 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 --- !u!114 &11400000
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -852,19 +795,6 @@ MonoBehaviour:
intensity: intensity:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 0 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 --- !u!114 &7173750748008157695
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 3 m_ObjectHideFlags: 3
@@ -961,22 +891,3 @@ MonoBehaviour:
blueOutBlueIn: blueOutBlueIn:
m_OverrideState: 1 m_OverrideState: 1
m_Value: 100 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
@@ -33,7 +33,8 @@ MonoBehaviour:
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50} m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
m_autoSizeTextContainer: 0 m_autoSizeTextContainer: 0
m_IsTextObjectScaleStatic: 0 m_IsTextObjectScaleStatic: 0
m_fallbackFontAssets: [] m_fallbackFontAssets:
- {fileID: 11400000, guid: f6c6fe0f3c5912a43a8a6707e336d2ea, type: 2}
m_matchMaterialPreset: 1 m_matchMaterialPreset: 1
m_HideSubTextObjects: 1 m_HideSubTextObjects: 1
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45, 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_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208} m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3} m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name: m_Name:
@@ -170,7 +170,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0 m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0 m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0 m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0 m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0 m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0} m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0 m_PlayAudioClipOnSelectExited: 0
@@ -528,7 +528,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208} m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3} m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name: m_Name:
@@ -760,7 +760,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494} m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3} m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name: m_Name:
@@ -854,7 +854,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0 m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0 m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0 m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0 m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0 m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0} m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0 m_PlayAudioClipOnSelectExited: 0
@@ -1212,7 +1212,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494} m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3} m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name: m_Name:
@@ -411,7 +411,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668446538128435} m_GameObject: {fileID: 8546668446538128435}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3} m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name: m_Name:
@@ -1003,7 +1003,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668447105775893} m_GameObject: {fileID: 8546668447105775893}
m_Enabled: 1 m_Enabled: 0
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3} m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name: m_Name:
@@ -1295,7 +1295,7 @@ Camera:
m_GameObject: {fileID: 8546668447772986810} m_GameObject: {fileID: 8546668447772986810}
m_Enabled: 1 m_Enabled: 1
serializedVersion: 2 serializedVersion: 2
m_ClearFlags: 2 m_ClearFlags: 1
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0} m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
m_projectionMatrixMode: 1 m_projectionMatrixMode: 1
m_GateFitMode: 2 m_GateFitMode: 2
@@ -342,7 +342,7 @@ MonoBehaviour:
handSettings: handSettings:
interactPoint: {fileID: 3074267110786978836} interactPoint: {fileID: 3074267110786978836}
highlightPoint: {fileID: 3074267110786978836} highlightPoint: {fileID: 3074267110786978836}
rotationOffset: {x: 0, y: 90, z: 25} rotationOffset: {x: 25, y: 0, z: 0}
canInteract: 1 canInteract: 1
rightHandAnimationSettings: rightHandAnimationSettings:
animation: {fileID: 0} animation: {fileID: 0}
@@ -522,7 +522,7 @@ MonoBehaviour:
m_GameObject: {fileID: 5575416034875238503} m_GameObject: {fileID: 5575416034875238503}
m_Enabled: 1 m_Enabled: 1
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c68346ff56573a1429560a527ad447e0, type: 3} m_Script: {fileID: 11500000, guid: 4de824eda67bd1c4ba4d379a9debd2b3, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: m_EditorClassIdentifier:
fastCollisionListener: {fileID: 5407220909436794986} fastCollisionListener: {fileID: 5407220909436794986}
@@ -533,6 +533,7 @@ MonoBehaviour:
hitForce: 0 hitForce: 0
maxHitForce: 0 maxHitForce: 0
canDismember: 0 canDismember: 0
colorSide: 1
--- !u!114 &5407220909436794986 --- !u!114 &5407220909436794986
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
+4 -4
View File
@@ -2639,7 +2639,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 661667650} m_GameObject: {fileID: 661667650}
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956} m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 19.76} m_LocalPosition: {x: 0, y: 0, z: 20.29}
m_LocalScale: {x: 0.25, y: 0.25, z: 1} m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: m_Children:
@@ -2652,7 +2652,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {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: 24.01, y: 4.71}
m_SizeDelta: {x: 105.885, y: 71.226} m_SizeDelta: {x: 105.885, y: 71.226}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &661667652 --- !u!114 &661667652
@@ -7157,7 +7157,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1946485404} m_GameObject: {fileID: 1946485404}
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956} m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 17.9} m_LocalPosition: {x: 0, y: 0, z: 18.33}
m_LocalScale: {x: 0.25, y: 0.25, z: 1} m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: m_Children:
@@ -7167,7 +7167,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0} m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {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: -21.77, y: 4.39}
m_SizeDelta: {x: 105.89, y: 66.53} m_SizeDelta: {x: 105.89, y: 66.53}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1946485406 --- !u!114 &1946485406
@@ -39,6 +39,11 @@ namespace VRBeats
if (Cut(info.hitPoint, cutDir, insideMaterial)) 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); Destroy(gameObject);
} }
} }
@@ -0,0 +1,450 @@
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, 0.45f);
BuildRibbon(coreMesh, now, 0.42f, 1f, coreColor, 0.14f, 0.48f);
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();
ApplyMaterialColor(wideMaterial, trailColor, 0.34f);
Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f);
ApplyMaterialColor(coreMaterial, coreColor, 0.50f);
}
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 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,140 @@
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;
glowLine = CreateLine("Glow", 0.16f, 0.45f);
coreLine = CreateLine("Core", 0.045f, 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;
UpdateLine(glowLine, length, bend, alpha * 0.45f);
UpdateLine(coreLine, length * 0.88f, bend * 0.55f, alpha * 0.95f);
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;
}
}
}
@@ -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 //notify to whoever is listening that the player did a correct/incorrect slice
if ( IsCutIntentValid(info as BeatDamageInfo) ) if ( IsCutIntentValid(info as BeatDamageInfo) )
{ {
ScoreManager.ReportSliceTiming(GetTimingErrorSeconds());
onCorrectSlice.Invoke(); onCorrectSlice.Invoke();
} }
else else
{ {
ScoreManager.ReportMiss();
onIncorrectSlice.Invoke(); onIncorrectSlice.Invoke();
} }
@@ -98,6 +100,7 @@ namespace VRBeats
public void Kill() public void Kill()
{ {
ScoreManager.ReportMiss();
onPlayerMiss.Invoke(); onPlayerMiss.Invoke();
canBeKilled = false; canBeKilled = false;
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate 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 Platinio;
using UnityEngine.Playables; using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using VRBeats.ScriptableEvents; using VRBeats.ScriptableEvents;
using VRSDK; using VRSDK;
@@ -60,24 +62,22 @@ namespace VRBeats
Vector3 finalPosition = CalculateSpawnPosition( info.position); Vector3 finalPosition = CalculateSpawnPosition( info.position);
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance; Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
Vector3 spawnPosition = finalPosition - travelOffset; 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; 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(); clone.OnSpawn();
}).SetUpdateMode(Platinio.TweenEngine.UpdateMode.Update);
clone.transform.ScaleTween(finalScale, travelTime).SetEase(settings.TargetTravelEase);
} }
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info) private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
@@ -122,13 +122,7 @@ namespace VRBeats
public void RestartLevel() public void RestartLevel()
{ {
gameObject.CancelAllTweens(); SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
isGameRunning = true;
audioManager.SetAudioMixerPitch(1.0f);
enviromentController.TurnLightsOn();
playableDirector.time = 0.0f;
playableDirector.Play();
} }
} }
@@ -39,9 +39,9 @@ namespace VRBeats
public void EnableXRRayInteractorComponents() public void EnableXRRayInteractorComponents()
{ {
if (rayInteractor != null) if (rayInteractor != null)
rayInteractor.enabled = true; rayInteractor.enabled = false;
if (interactorLineVisual != null) if (interactorLineVisual != null)
interactorLineVisual.enabled = true; interactorLineVisual.enabled = false;
if (lineRender != null) if (lineRender != null)
lineRender.enabled = true; lineRender.enabled = true;
+12 -1
View File
@@ -15,10 +15,14 @@ namespace VRBeats
private VR_Grabbable grabbable = null; private VR_Grabbable grabbable = null;
private ColorSide colorSide = ColorSide.Left; private ColorSide colorSide = ColorSide.Left;
private MeshRenderer[] renderArray = null; private MeshRenderer[] renderArray = null;
private SaberTrailEffect trailEffect = null;
private void Awake() private void Awake()
{ {
renderArray = transform.GetComponentsInChildren<MeshRenderer>(); renderArray = transform.GetComponentsInChildren<MeshRenderer>();
trailEffect = GetComponent<SaberTrailEffect>();
if (trailEffect == null)
trailEffect = gameObject.AddComponent<SaberTrailEffect>();
grabbable = GetComponent<VR_Grabbable>(); grabbable = GetComponent<VR_Grabbable>();
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange); grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
@@ -44,6 +48,9 @@ namespace VRBeats
{ {
SetMaterialBindings(materialBindingArray[n], c); SetMaterialBindings(materialBindingArray[n], c);
} }
if (trailEffect != null)
trailEffect.SetColor(c);
} }
private void SetMaterialBindings(MaterialBindings matBindings, Color c) private void SetMaterialBindings(MaterialBindings matBindings, Color c)
@@ -55,11 +62,15 @@ namespace VRBeats
public void MakeVisible() public void MakeVisible()
{ {
SetRenderArrayEnableValue(true); SetRenderArrayEnableValue(true);
if (trailEffect != null)
trailEffect.SetVisible(true);
} }
public void MakeInvisible() public void MakeInvisible()
{ {
SetRenderArrayEnableValue(false); SetRenderArrayEnableValue(false);
if (trailEffect != null)
trailEffect.SetVisible(false);
} }
private void SetRenderArrayEnableValue(bool value) private void SetRenderArrayEnableValue(bool value)
@@ -72,4 +83,4 @@ namespace VRBeats
} }
} }
@@ -13,24 +13,42 @@ namespace VRBeats
private void Start() private void Start()
{ {
if (colorSide == ColorSide.Left) controller = VR_Manager.instance.Player.LeftController; ResolveController();
if (colorSide == ColorSide.Right) controller = VR_Manager.instance.Player.RightController;
} }
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint) protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
{ {
ResolveController();
var damageInfo = base.CreateDamageInfo(hitPoint); var damageInfo = base.CreateDamageInfo(hitPoint);
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo); 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.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
beatDamageInfo.hitObject = gameObject; beatDamageInfo.hitObject = gameObject;
beatDamageInfo.colorSide = colorSide; beatDamageInfo.colorSide = colorSide;
beatDamageInfo.velocity = controller.Velocity.magnitude; beatDamageInfo.velocity = controllerVelocity.magnitude;
return beatDamageInfo; 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;
}
} }
} }
@@ -32,7 +32,7 @@ namespace VRBeats
public Vector3 rotation = Vector3.zero; public Vector3 rotation = Vector3.zero;
public float speed = 2.0f; public float speed = 2.0f;
public int speedMultiplier = 1; public int speedMultiplier = 1;
// 0 이면 Settings.TargetTravelTime 사용, 양수면 해당 시간로 이동 // 0이면 Settings.TargetTravelTime 사용, 양수면 해당 시간 동안 일정 속도로 이동
public float travelTimeOverride = 0f; public float travelTimeOverride = 0f;
} }
} }
@@ -21,21 +21,30 @@ namespace VRBeats
} }
scoreManager = FindFirstObjectByType<ScoreManager>(); scoreManager = FindFirstObjectByType<ScoreManager>();
ApplyPopupTextStyle();
} }
public void ShowScore() public void ShowScore()
{ {
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , scoreFadeTime).SetOnUpdateFloat(delegate (float v) if (scoreText == null || scoreManager == null)
return;
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , Mathf.Min(scoreFadeTime, 0.8f)).SetOnUpdateFloat(delegate (float v)
{ {
SetScore( (int)v ); SetScore( (int)v );
}).SetOnComplete(delegate
{
scoreText.text = scoreManager.BuildResultSummary(length);
}); });
} }
public void ResetValues() public void ResetValues()
{ {
gameObject.CancelAllTweens(); gameObject.CancelAllTweens();
scoreText.text = initialValue; ApplyPopupTextStyle();
if (scoreText != null)
scoreText.text = initialValue;
} }
@@ -56,5 +65,20 @@ namespace VRBeats
} }
private void ApplyPopupTextStyle()
{
if (scoreText == null)
return;
scoreText.enableAutoSizing = false;
scoreText.fontSize = 4.4f;
scoreText.alignment = TextAlignmentOptions.Center;
scoreText.overflowMode = TextOverflowModes.Overflow;
scoreText.textWrappingMode = TextWrappingModes.NoWrap;
scoreText.lineSpacing = -18.0f;
scoreText.color = Color.white;
scoreText.richText = true;
}
} }
} }
+411 -96
View File
@@ -1,4 +1,4 @@
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using Platinio.TweenEngine; using Platinio.TweenEngine;
using VRBeats.ScriptableEvents; using VRBeats.ScriptableEvents;
@@ -7,6 +7,14 @@ namespace VRBeats
{ {
public class ScoreManager : MonoBehaviour public class ScoreManager : MonoBehaviour
{ {
private enum BeatJudgement
{
Perfect,
Great,
Good,
Miss
}
[SerializeField] private Text multiplierLabel = null; [SerializeField] private Text multiplierLabel = null;
[SerializeField] private Text scoreLabel = null; [SerializeField] private Text scoreLabel = null;
[SerializeField] private Image multiplierLoader = null; [SerializeField] private Image multiplierLoader = null;
@@ -14,61 +22,167 @@ namespace VRBeats
[SerializeField] private CanvasGroup canvasGroup = null; [SerializeField] private CanvasGroup canvasGroup = null;
[SerializeField] private GameEvent onGameOver = 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.08f;
[SerializeField] private float greatWindow = 0.15f;
[SerializeField] private float goodWindow = 0.25f;
private int maxMultiplier = 0; private int maxMultiplier = 0;
private int scorePerHit = 0; private const int MaxCourseScore = 1000000;
private int currentScore = 0; private float currentMultiplier = 1.0f;
private int currentMultiplier = 0;
private int toNextMultiplierIncrease = 2;
private int acumulateCorrectSlices = 0;
private int acumulateErrors = 0; private int acumulateErrors = 0;
private int errorLimit = 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 float visualScore = 0.0f;
private int scoreTweenID = -1; private int scoreTweenID = -1;
private int loaderTweenID = -1; private int loaderTweenID = -1;
private BeatJudgement lastJudgement = BeatJudgement.Perfect;
private float judgementTimer = 0.0f;
private Text progressLabel = null;
private Text rankLabel = 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 bool destroyed = false;
public int CurrentScore private static bool hasPendingSliceTiming = false;
private static float pendingSliceTiming = 0.0f;
public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f);
public float AccuracyPercent
{ {
get get
{ {
return currentScore; int denominatorNotes = totalNoteCount > 0 ? totalNoteCount : judgedNoteCount;
if (denominatorNotes <= 0)
return 100.0f;
return (float)earnedAccuracyPoints / (denominatorNotes * 1000) * 100.0f;
}
}
public string Rank
{
get
{
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";
} }
} }
private void Awake() private void Awake()
{ {
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier; maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit; errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
multiplierLoader.fillAmount = 0.0f;
} if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
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 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)
{
songCurrentTime = Mathf.Max(0.0f, currentTime);
songDuration = Mathf.Max(0.0f, duration);
}
public void OnGameOver() public void OnGameOver()
{ {
gameObject.CancelAllTweens(); gameObject.CancelAllTweens();
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); if (canvasGroup != null)
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
} }
public void OnGameRestart() public void OnGameRestart()
{ {
ResetThisComponent(); ResetThisComponent();
gameObject.CancelAllTweens(); gameObject.CancelAllTweens();
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); if (canvasGroup != null)
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
} }
public void ResetThisComponent() public void ResetThisComponent()
{ {
currentMultiplier = 0; currentMultiplier = 1.0f;
currentScore = 0;
acumulateCorrectSlices = 0;
visualScore = 0; visualScore = 0;
acumulateErrors = 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() private void Update()
{ {
UpdateUI(); UpdateUI();
@@ -79,46 +193,13 @@ namespace VRBeats
if (destroyed) if (destroyed)
return; return;
acumulateErrors = 0; BeatJudgement judgement = ConsumeJudgement();
acumulateCorrectSlices++; RegisterJudgement(judgement);
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
CancelTweenById(scoreTweenID); acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
visualScore = value;
}).ID;
UpdateScoreTween();
UpdateMultiplierLoaderValue(); 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() public void OnIncorrectSlice()
@@ -126,18 +207,63 @@ namespace VRBeats
if (destroyed) if (destroyed)
return; return;
RegisterJudgement(BeatJudgement.Miss);
acumulateErrors++; acumulateErrors++;
acumulateCorrectSlices = 0; currentMultiplier = 1.0f;
currentMultiplier = 0;
toNextMultiplierIncrease = 2;
UpdateScoreTween();
UpdateMultiplierLoaderValue(); UpdateMultiplierLoaderValue();
if (acumulateErrors > errorLimit) if (acumulateErrors > errorLimit)
{
onGameOver.Invoke(); onGameOver.Invoke();
} }
public string BuildResultSummary(int minScoreLength)
{
string score = CurrentScore.ToString();
score = new string('0', Mathf.Max(minScoreLength - score.Length, 0)) + score;
string badge = missCount == 0
? (greatCount == 0 && goodCount == 0 ? "PERFECT PLAY" : "FULL COMBO")
: "TRY AGAIN";
return $"<size=150%><color=#41F2FF>{Rank}</color></size>\n" +
$"<size=118%>{score}</size>\n" +
$"<size=56%><color=#D7F7FF>ACC {AccuracyPercent:0.0}% {badge}</color></size>\n" +
$"<size=50%>MAX COMBO {maxCombo}</size>\n" +
$"<size=42%><color=#A9B7C0>P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}</color></size>";
}
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() private void UpdateUI()
@@ -145,41 +271,230 @@ namespace VRBeats
if (destroyed) if (destroyed)
return; return;
multiplierLabel.text = currentMultiplier.ToString(); if (multiplierLabel != null)
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString(); multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}";
} if (scoreLabel != null)
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
if (comboLabel != null)
comboLabel.text = currentCombo > 0
? $"<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>{currentCombo}</size>"
: "<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>0</size>";
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)}"
: "";
private void IncreaseMultiplier() if (judgementLabel == null)
{
if (destroyed)
return; return;
acumulateCorrectSlices = 0; judgementTimer -= Time.deltaTime;
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier ); judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : "";
toNextMultiplierIncrease = (currentMultiplier + 1) * 2; judgementLabel.color = GetJudgementColor(lastJudgement);
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);
}
} );
} }
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 += 700;
currentCombo++;
}
else if (judgement == BeatJudgement.Good)
{
goodCount++;
earnedAccuracyPoints += 400;
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 >= 200) return 1.5f;
if (combo >= 100) return 1.35f;
if (combo >= 50) return 1.2f;
if (combo >= 20) 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 = 20;
if (currentCombo >= 200) return 1.0f;
if (currentCombo >= 100) { lower = 100; upper = 200; }
else if (currentCombo >= 50) { lower = 50; upper = 100; }
else if (currentCombo >= 20) { lower = 20; upper = 50; }
return Mathf.InverseLerp(lower, upper, currentCombo);
}
private void PrepareHud()
{
RectTransform rect = transform as RectTransform;
if (applyHudPlacement && rect != null)
rect.anchoredPosition = hudAnchoredPosition;
ConfigureText(scoreLabel, new Vector2(-255.0f, -18.0f), new Vector2(220.0f, 40.0f), 26, Color.white, TextAnchor.MiddleLeft);
ConfigureText(multiplierLabel, new Vector2(255.0f, 36.0f), new Vector2(100.0f, 68.0f), 34, Color.white, TextAnchor.MiddleCenter);
ConfigureImage(multiplierLoader, new Vector2(255.0f, 36.0f), new Vector2(104.0f, 104.0f));
if (!createMissingHudLabels)
return;
comboLabel ??= CreateHudText("Combo", new Vector2(-255.0f, 74.0f), new Vector2(220.0f, 112.0f), 36, Color.white, TextAnchor.MiddleLeft);
accuracyLabel ??= CreateHudText("Accuracy", new Vector2(-255.0f, -58.0f), new Vector2(220.0f, 34.0f), 20, new Color(0.88f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleLeft);
rankLabel ??= CreateHudText("Rank", new Vector2(-255.0f, -105.0f), new Vector2(220.0f, 76.0f), 48, Color.white, TextAnchor.MiddleLeft);
judgementLabel ??= CreateHudText("Judgement", new Vector2(0.0f, 112.0f), new Vector2(260.0f, 54.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter);
progressLabel ??= CreateHudText("SongProgress", new Vector2(255.0f, -62.0f), new Vector2(180.0f, 34.0f), 18, Color.white, TextAnchor.MiddleCenter);
comboBaseScale = comboLabel != null ? comboLabel.transform.localScale : Vector3.one;
}
private Text CreateHudText(string name, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
{
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>();
ConfigureText(text, anchoredPosition, size, fontSize, color, alignment);
return text;
}
private static void ConfigureText(Text text, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
{
if (text == null)
return;
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)
{
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 = new Color(1.0f, 1.0f, 1.0f, 0.85f);
image.raycastTarget = false;
}
private string GetRankColorHex()
{
switch (Rank)
{
case "S+": return "#41F2FF";
case "S": return "#69FFD1";
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}";
}
} }
} }
+2 -2
View File
@@ -14,9 +14,9 @@ MonoBehaviour:
m_EditorClassIdentifier: m_EditorClassIdentifier:
rightColor: {r: 0, g: 0.6002884, b: 1, a: 1} rightColor: {r: 0, g: 0.6002884, b: 1, a: 1}
leftColor: {r: 1, g: 0, b: 0, a: 1} leftColor: {r: 1, g: 0, b: 0, a: 1}
glowIntensity: 100 glowIntensity: 40
targetTravelDistance: 40 targetTravelDistance: 40
targetTravelTime: 1.8 targetTravelTime: 3.2
targetTravelEase: 19 targetTravelEase: 19
errorLimit: 7 errorLimit: 7
scorePerHit: 50 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:
+82 -13
View File
@@ -7,16 +7,16 @@ Meta Quest용 VR Beat Saber 클론. Beat Sage API로 노래를 자동 채보하
--- ---
## 현재 상태 (2026-05-26) ## 현재 상태 (2026-05-28)
현재 저장소는 VRBeatsKit 기반 프로젝트 위에 커스텀 곡 선택/생성/NAS 연동 흐름을 붙인 상태다. 현재 저장소는 VRBeatsKit 기반 프로젝트 위에 커스텀 곡 선택/생성/NAS 연동, 360도 영상 배경, 점수/콤보 개편, 싱크 보정 화면, VR UI 포인터 보정, 세이버/큐브 트레일 효과를 붙인 상태다.
- Unity 버전: `6000.3.12f1` - Unity 버전: `6000.3.12f1`
- 현재 브랜치: `main` - 현재 브랜치: `master`
- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git` - 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git`
- 최근 푸시 커밋: `182d2c9 fix: stabilize VR UI and song playback`
- `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개 - `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개
- 현재 워킹트리에는 큐브 간격 보정과 경고 제거 작업이 커밋 전 변경으로 남아 있다. - NAS 비밀번호/세션 파일은 로컬 전용이다. `env`, `cookies.txt`, `Assets/StreamingAssets/nas_config.json`, Unity `_Recovery`는 커밋하지 않는다.
- Unity Codex Bridge는 에디터 내부 HTTP 브리지와 MCP 서버를 통해 씬/로그/캡처를 조회하는 용도다. 포트 충돌 시 Unity 재시작 또는 기존 포트 점유 프로세스 정리가 필요하다.
### 실제 씬 구성 ### 실제 씬 구성
@@ -52,7 +52,76 @@ SongCreator.unity
-> NasPublisher가 mp3, map json, songs.json을 Synology NAS에 업로드 -> NasPublisher가 mp3, map json, songs.json을 Synology NAS에 업로드
``` ```
### 최근 반영된 변경 ### 2026-05-28 최근 반영된 변경
#### 게임 플레이/노트/점수
- `Assets/Script/SongController.cs`
- 큐브가 중간에 멈추거나 급가속하는 느낌을 줄이기 위해 노트를 일정 속도로 접근시키는 흐름으로 조정했다.
- 노트 도착 기준을 오디오 시간과 맞추고, 난이도별 노트 수가 적어도 랭크가 과도하게 낮게 나오지 않도록 총 노트 기준 점수/랭크 구조와 맞물리게 했다.
- `Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`, `FinalScoreLabel.cs`
- DJMAX 계열에 가까운 콤보/정확도 중심 점수 구조로 개편했다.
- 총 노트 수 기준으로 정확도와 랭크를 계산하므로 Normal처럼 노트 수가 적은 곡도 구조적으로 F에 고정되지 않는다.
- 재시작/리플레이 시 이전 점수, 콤보, 음악 상태가 남지 않도록 초기화 흐름을 보강했다.
- `Assets/VRBeatsKit/Scripts/Core/VR_BeatCube.cs`, `Cuttable.cs`, `DamageSaber.cs`
- 정상 절단된 큐브가 뒤로 날아간 뒤 Miss로 다시 잡히는 문제를 막기 위해 절단/미스 상태 흐름을 보강했다.
- 방향/색상/속도 판정과 절단 시각 효과가 엇갈리지 않도록 유효 절단 조건을 정리했다.
- `Assets/VRBeatsKit/Scripts/Spawneable/SpawnEventInfo.cs`, `VR_BeatManager.cs`
- 노트 스폰 타이밍과 이동 속도 보정에 필요한 정보를 전달하도록 확장했다.
#### VR UI/입력
- `Assets/Script/VRPointerController.cs`, `VRPointerSetup.cs`
- VR 컨트롤러 레이 버튼 클릭 안정성을 보강했다.
- 비활성 Canvas의 버튼이 레이캐스트 후보에 남아 클릭을 빼앗는 문제를 필터링했다.
- SongSelect 스크롤 속도를 올리고, 검지 트리거를 누른 상태로 위/아래 드래그해 스크롤할 수 있게 했다.
- 메뉴/게임 씬 전환 후에도 포인터가 다시 주입되도록 유지했다.
- `Assets/VRBeatsKit/Prefabs/PlayerSetup/MenuPlayer.prefab`, `SaberStylePlayer.prefab`, `VR_InteractorController.cs`
- XR Interaction Toolkit의 `XRRayInteractor`/`XRInteractorLineVisual`과 커스텀 포인터가 겹쳐 버튼 입력이 꼬이는 문제를 막기 위해 XRI 레이 시각화를 비활성화했다.
- `Missing ILineRenderable / Ray Interactor component` 오류가 반복되지 않도록 런타임 enable 흐름도 커스텀 포인터 기준으로 정리했다.
#### 비주얼/배경/세이버
- `Assets/Script/Game360VideoBackground.cs`, `Assets/Scenes/Game.unity`
- 게임 씬 배경을 360도 영상처럼 둘러싼 내부 구체/스카이박스형 배경으로 재생하도록 추가했다.
- 영상은 Unity 호환 H.264 baseline/CFR MP4로 변환해 쓰는 것을 권장한다. 원본에 timestamp warning이 있으면 `ffmpeg`로 재인코딩한다.
- `Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs`
- 기존 검끝 한 점 `TrailRenderer` 방식 대신, 세이버 `Start-End` 검신 전체를 샘플링하는 월드 스페이스 리본 메쉬 잔상으로 교체했다.
- 검끝에서만 빙글 도는 헬리콥터 같은 잔상 대신, 검신이 휘둘린 면이 짧게 남는 형태다.
- `Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs`
- 큐브가 잘릴 때 절단면/파편 쪽에서 짧은 트레일이 남도록 별도 효과를 추가했다.
- `Assets/VRBeatsKit/Settings/Settings.asset`, `DefaultVolumeProfile.asset`
- 큐브 네온/블룸 강도를 낮춰 과한 발광을 줄였다.
- `Assets/VRBeatsKit/Prefabs/VR_Saber/2/RightFuturisticSword.prefab`
- 두 번째 세이버도 너무 수직으로 서지 않도록 프리팹 회전을 보정했다.
#### 싱크 보정/개발 도구
- `Assets/Script/GlobalSyncSettings.cs`, `SyncCalibrationOverlay.cs`, `MenuSyncButtonInjector.cs`
- 메뉴의 Create Song 버튼 아래에 Sync 버튼을 주입하고, 별도 싱크 보정 화면을 열 수 있게 했다.
- 주기적인 틱 사운드/시각 기준을 보고 오른쪽 컨트롤러 A 버튼 또는 키보드로 입력 지연을 기록하는 구조다.
- TextMeshPro 한글 깨짐을 줄이기 위해 `TMP Settings.asset`에 NanumGothic fallback을 추가했다.
- `Assets/Editor/UnityCodexBridgeServer.cs`, `tools/unity-mcp-server/`, `docs/unity_mcp_bridge.md`
- Codex가 Unity 에디터의 health/log/scene/capture/play state를 조회할 수 있는 로컬 브리지와 MCP 서버를 추가했다.
- 현재 MCP 연결은 세션/포트 상태에 따라 재시작이 필요할 수 있다.
#### NAS/다운로드/생성
- `Assets/Script/NasPublisher.cs`
- `nas_config.json` 또는 로컬 `env` 기반으로 NAS 계정/비밀번호를 읽도록 정리했다.
- DSM 응답 파싱 실패/비밀번호 누락/URL 공백 문제를 더 명확히 로그로 남기도록 보강했다.
- `Assets/Script/DownloadManager.cs`, `SongLibrary.cs`
- 곡이 실제로 다운로드되는 경로를 로그와 상태로 확인할 수 있게 했다.
- 곡 삭제 시 mp3/map json/다운로드 상태가 같이 지워지도록 정리해 캐시가 쌓이기만 하는 문제를 줄였다.
- `Assets/Script/BeatSageConverter.cs`, `BeatSageUploader.cs`
- Beat Sage 변환 결과와 노트 수 로그를 확인하기 쉽게 유지했다.
#### 로컬 전용/커밋 제외
- `env`, `cookies.txt`, `Assets/_Recovery/`는 로컬 전용이라 커밋하지 않는다.
- `tools/unity-mcp-server/node_modules/`도 커밋 제외다.
### 2026-05-26 이전 반영된 변경
- `Assets/Script/SongController.cs` - `Assets/Script/SongController.cs`
- Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다. - Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다.
@@ -94,13 +163,13 @@ SongCreator.unity
### 현재 주의사항 ### 현재 주의사항
1. `Assets/StreamingAssets/nas_config.json`은 현재 저장소에 없다. NAS 업로드 테스트하려면 로컬에 직접 만들어야 하며, 절대 커밋하지 않는다. 1. `Assets/StreamingAssets/nas_config.json`과 루트 `env`는 로컬 전용이다. NAS 업로드 테스트 전 계정/비밀번호를 직접 넣되 절대 커밋하지 않는다.
2. `SongCreator.unity`의 직렬화된 `nasBaseUrl` 값에 끝 공백이 들어가 있다: `http://whdwo798.synology.me:5000 `. 런타임에서 `nas_config.json`으로 덮어쓰지 않으면 로그인 URL 문제가 날 수 있다. 2. Unity 콘솔의 `Unable to start Oculus XR Plugin`은 헤드셋/오큘러스 런타임 상태 문제일 수 있다. PC 에디터 단독 실행에서는 경고가 날 수 있다.
3. `SongCreatorManager`는 난이도 토글 필드를 갖고 있지만 현재 로직은 4개 난이도(`normal`, `hard`, `expert`, `expertplus`)를 항상 전부 생성한다. 3. `The referenced script (Unknown) on this Behaviour is missing!` 경고가 남는 씬/프리팹은 추가 확인 대상이다. 게임 진행을 막는 직접 원인은 아니지만, 인스펙터에서 Missing Script를 정리하는 것이 좋다.
4. `manualEditorButton`은 씬에서 미연결이고 코드에서도 사용하지 않는다. 4. 영상 배경은 Unity 호환 MP4가 안전하다. H.264 timestamp warning이 뜨는 원본은 baseline profile, CFR, yuv420p로 재인코딩한다.
5. `Assets/img/360.mp4`는 약 192MB다. 현재 일반 Git으로 푸시되어 있으므로, 원격 정책이 바뀌면 Git LFS 전환을 검토해야 한다. 5. `Assets/img` 아래 영상 파일은 크기가 커질 수 있다. 원격 저장소 정책에 걸리면 Git LFS 전환을 검토한다.
6. 큐브 간격은 수치상 겹침을 피하도록 넓혔지만, 실제 Quest 착용 테스트에서 손 위치/판정 거리/시야 피로도를 확인해야 한다. 6. 싱크 보정 화면은 UI/입력 구조까지 들어갔지만, 기기별 오디오 지연 값은 Quest 실기에서 측정해야 한다.
7. SongCreator에서 생성 직후 첫 재생이 곡에 따라 늦거나 싱크가 흔들리는 체감이 있었다. 게임 씬 오디오 기준은 `AudioSettings.dspTime`으로 개선했지만, 생성/다운로드/첫 로드 전체 파이프라인은 추가 로그 검증이 필요하다. 7. 세이버 잔상, 큐브 이동 속도, 블룸 강도는 체감 튜닝 항목이다. 현재 값은 빌드/컴파일 기준으로 안전하지만, VR 착용 테스트 후 수치를 조정하는 것이 좋다.
--- ---
+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
```
+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"
+178
View File
@@ -0,0 +1,178 @@
#!/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 UNITY_BRIDGE_URL = (process.env.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 fetch(`${UNITY_BRIDGE_URL}${path}`, {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
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;
}
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"
}
}