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
.env
/env
/cookies.txt
/Assets/StreamingAssets/nas_config.json
/Assets/StreamingAssets/nas_config.json.meta
# Local tool output
/Captures/
/tools/unity-mcp-server/node_modules/
/Assets/_Recovery/
/Assets/_Recovery.meta
# Local video sources / superseded test clips
/Assets/img/*.mkv
/Assets/img/*.mkv.meta
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4.meta
/Assets/img/neon_background_unity.mp4
/Assets/img/neon_background_unity.mp4.meta
+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,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: colorSide
@@ -627,12 +632,12 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.9239
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: 0.3827
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -647,7 +652,7 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 45
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -1263,7 +1268,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: -7.5, y: -1.7}
m_AnchoredPosition: {x: 5.8, y: 2.4}
m_SizeDelta: {x: 847.5, y: 1141.086}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &454873730
@@ -1284,6 +1289,15 @@ MonoBehaviour:
scoreFollowTime: 1
canvasGroup: {fileID: 454873732}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
comboLabel: {fileID: 0}
accuracyLabel: {fileID: 0}
judgementLabel: {fileID: 0}
createMissingHudLabels: 1
applyHudPlacement: 1
hudAnchoredPosition: {x: 5.8, y: 2.4}
perfectWindow: 0.08
greatWindow: 0.15
goodWindow: 0.25
--- !u!114 &454873731
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -2380,7 +2394,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_destroyOnLoad: 1
currentSDK: 0
currentSDK: 3
gestureConfig:
minAcelerationThreshold: 15
maxAcelerationThreshold: 40
@@ -2652,8 +2666,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: -1.4000015, y: 2.5099983}
m_SizeDelta: {x: 20.150002, y: 6.41}
m_AnchoredPosition: {x: 0, y: -0.45}
m_SizeDelta: {x: 18.8, y: 13.2}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1209841520
GameObject:
@@ -2692,7 +2706,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -16.119999, y: -15.380001}
m_AnchoredPosition: {x: -17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1209841522
@@ -2825,7 +2839,6 @@ MonoBehaviour:
playZone: {fileID: 778115775}
player: {fileID: 408071456}
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
--- !u!4 &1215359035
Transform:
m_ObjectHideFlags: 0
@@ -3790,8 +3803,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 7.1
m_fontSizeBase: 7.1
m_fontSize: 4.4
m_fontSizeBase: 4.4
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -3803,7 +3816,7 @@ MonoBehaviour:
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacing: -18
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
@@ -4376,6 +4389,7 @@ GameObject:
m_Component:
- component: {fileID: 1958381893}
- component: {fileID: 1958381892}
- component: {fileID: 1958381894}
m_Layer: 0
m_Name: SongController
m_TagString: Untagged
@@ -4414,6 +4428,23 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1958381894
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1958381891}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3e381cd99de84f67b9f83c19a032dc24, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Game360VideoBackground
videoClip: {fileID: 32900000, guid: f47e26c36f77476ba803b5158d1b30da, type: 3}
renderTextureSize: 2048
muteVideoAudio: 1
skyboxRotationDegrees: 90
skyboxExposure: 1
--- !u!4 &2043306906 stripped
Transform:
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
@@ -4457,7 +4488,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 15.980001, y: -15.380001}
m_AnchoredPosition: {x: 17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2094521063
@@ -4857,6 +4888,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -4865,13 +4901,23 @@ PrefabInstance:
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: m_RootOrder
@@ -5157,6 +5203,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -5165,13 +5216,23 @@ PrefabInstance:
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: m_Name
@@ -5277,7 +5338,7 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -5289,6 +5350,11 @@ PrefabInstance:
propertyPath: startOnRightcController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: controller
@@ -5317,27 +5383,27 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.99958926
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: -0
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.y
value: -0.028659718
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.z
value: -0
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
+1 -1
View File
@@ -8107,7 +8107,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
nasBaseUrl: 'http://whdwo798.synology.me:5000 '
nasBaseUrl: http://whdwo798.synology.me:5000
nasAccount: beatSaber_app
nasRootPath: /web/beatsaber
staticBaseUrl: http://whdwo798.synology.me/beatsaber
+4 -1
View File
@@ -36,6 +36,8 @@ public class BeatSageNote
public static class BeatSageConverter
{
private static readonly bool LogConversions = false;
public static List<NoteData> Convert(string rawJson, float bpm)
{
var result = new List<NoteData>();
@@ -62,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;
}
+53 -2
View File
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
string legacyDir = LegacySongDir(songId);
if (Directory.Exists(legacyDir))
Directory.Delete(legacyDir, recursive: true);
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
string songDir = SongDir(song.id);
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
Directory.Delete(songDir);
}
public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId));
{
TryMigrateLegacySong(songId);
return File.Exists(AudioPath(songId));
}
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
return path != null && File.Exists(path);
}
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError)
{
TryMigrateLegacySong(song.id);
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
private static string LegacySongDir(string songId)
=> Path.Combine(LegacyCacheRoot, songId);
private static void TryMigrateLegacySong(string songId)
{
string sourceDir = LegacySongDir(songId);
string targetDir = SongDir(songId);
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
return;
CopyDirectory(sourceDir, targetDir);
Directory.Delete(sourceDir, recursive: true);
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
}
private static void CopyDirectory(string sourceDir, string targetDir)
{
Directory.CreateDirectory(targetDir);
foreach (string file in Directory.GetFiles(sourceDir))
{
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
File.Copy(file, targetFile, overwrite: true);
}
foreach (string dir in Directory.GetDirectories(sourceDir))
{
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
CopyDirectory(dir, targetSubDir);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using UnityEngine;
using UnityEngine.Video;
public class Game360VideoBackground : MonoBehaviour
{
[SerializeField] private VideoClip videoClip;
[SerializeField] private int renderTextureSize = 2048;
[SerializeField] private bool muteVideoAudio = true;
[SerializeField, Range(0f, 360f)] private float skyboxRotationDegrees = 0f;
[SerializeField, Range(0f, 8f)] private float skyboxExposure = 1f;
private GameObject videoPlayerObject;
private Material skyboxMaterial;
private Material previousSkybox;
private RenderTexture renderTexture;
private VideoPlayer videoPlayer;
private void Awake()
{
if (videoClip == null)
{
Debug.LogWarning("[Game360VideoBackground] videoClip is not assigned.");
return;
}
CreateSkyboxMaterial();
CreateVideoPlayer();
}
private void OnDestroy()
{
if (videoPlayer != null)
{
videoPlayer.prepareCompleted -= OnVideoPrepared;
videoPlayer.errorReceived -= OnVideoError;
}
if (renderTexture != null)
{
renderTexture.Release();
Destroy(renderTexture);
}
RenderSettings.skybox = previousSkybox;
DynamicGI.UpdateEnvironment();
if (skyboxMaterial != null)
Destroy(skyboxMaterial);
if (videoPlayerObject != null)
Destroy(videoPlayerObject);
}
private void CreateSkyboxMaterial()
{
renderTexture = new RenderTexture(renderTextureSize, renderTextureSize / 2, 0, RenderTextureFormat.ARGB32)
{
name = "Game360VideoRenderTexture",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
};
renderTexture.Create();
previousSkybox = RenderSettings.skybox;
skyboxMaterial = new Material(ResolveSkyboxShader())
{
name = "Game360VideoMaterial",
};
skyboxMaterial.SetTexture("_MainTex", renderTexture);
skyboxMaterial.SetFloat("_ImageType", 0f);
skyboxMaterial.SetFloat("_Mapping", 0f);
skyboxMaterial.SetFloat("_Layout", 0f);
ApplySkyboxSettings();
RenderSettings.skybox = skyboxMaterial;
DynamicGI.UpdateEnvironment();
}
private void CreateVideoPlayer()
{
videoPlayerObject = new GameObject("[360 Video Skybox Player]");
videoPlayerObject.transform.SetParent(transform, false);
videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
videoPlayer.playOnAwake = false;
videoPlayer.isLooping = true;
videoPlayer.waitForFirstFrame = true;
videoPlayer.renderMode = VideoRenderMode.RenderTexture;
videoPlayer.targetTexture = renderTexture;
videoPlayer.clip = videoClip;
videoPlayer.audioOutputMode = muteVideoAudio
? VideoAudioOutputMode.None
: VideoAudioOutputMode.Direct;
videoPlayer.prepareCompleted += OnVideoPrepared;
videoPlayer.errorReceived += OnVideoError;
videoPlayer.Prepare();
}
private static Shader ResolveSkyboxShader()
{
return Shader.Find("Skybox/Panoramic")
?? Shader.Find("Skybox/6 Sided")
?? Shader.Find("Standard");
}
private void OnVideoPrepared(VideoPlayer source)
{
source.Play();
}
private void OnValidate()
{
ApplySkyboxSettings();
}
private void ApplySkyboxSettings()
{
if (skyboxMaterial == null)
return;
skyboxMaterial.SetFloat("_Exposure", skyboxExposure);
skyboxMaterial.SetFloat("_Rotation", skyboxRotationDegrees);
DynamicGI.UpdateEnvironment();
}
private static void OnVideoError(VideoPlayer source, string message)
{
Debug.LogWarning($"[Game360VideoBackground] VideoPlayer error: {message}");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e381cd99de84f67b9f83c19a032dc24
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
public static class GlobalSyncSettings
{
private const string AudioOffsetMsKey = "VRBeats.GlobalAudioOffsetMs";
public static float AudioOffsetMs
{
get => PlayerPrefs.GetFloat(AudioOffsetMsKey, 0.0f);
set
{
PlayerPrefs.SetFloat(AudioOffsetMsKey, Mathf.Clamp(value, -300.0f, 300.0f));
PlayerPrefs.Save();
}
}
public static float AudioOffsetSeconds => AudioOffsetMs / 1000.0f;
public static void Reset()
{
PlayerPrefs.DeleteKey(AudioOffsetMsKey);
PlayerPrefs.Save();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2e8c518ec2f4a03a6d820774b475ce0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+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.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Networking;
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
private void LoadConfig()
{
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
if (!File.Exists(path))
{
NormalizeSettings();
return;
}
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
if (cfg == null) return;
_password = cfg.password ?? "";
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
NormalizeSettings();
}
[Serializable] private class NasConfig
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
Action onComplete,
Action<string> onError)
{
NormalizeSettings();
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
@@ -92,31 +101,53 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError)
{
if (string.IsNullOrWhiteSpace(_password))
{
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
yield break;
}
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
UnityWebRequest req;
try
{
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;
}
string resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
string resp;
using (req)
{
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))
onError?.Invoke("DSM sid parse failed — check credentials.");
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
}
private IEnumerator Logout()
{
NormalizeSettings();
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url);
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError)
{
NormalizeSettings();
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{
NormalizeSettings();
SongsList list = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
return null;
Match match = Regex.Match(
json,
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
return match.Success
? Regex.Unescape(match.Groups["value"].Value)
: null;
}
private static void AssignMapFile(SongInfo song, string diff, string fileName)
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
var info = song.difficulties.Get(diff);
if (info != null) info.mapFile = $"maps/{fileName}";
}
private void OnValidate()
{
NormalizeSettings();
}
private void NormalizeSettings()
{
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
nasAccount = nasAccount?.Trim() ?? "";
nasRootPath = NormalizeRootPath(nasRootPath);
}
private static string NormalizeBaseUrl(string value)
{
return (value ?? "").Trim().TrimEnd('/');
}
private static string NormalizeRootPath(string value)
{
value = (value ?? "").Trim().Replace('\\', '/');
if (string.IsNullOrEmpty(value))
return "/";
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
}
private static string Shorten(string value, int maxLength = 240)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value ?? "";
return value.Substring(0, maxLength) + "...";
}
}
+23 -7
View File
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
private const float VerticalCenter = 1f;
private AudioManager _audio;
private ScoreManager _scoreManager;
private float _clipLength;
private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber");
Path.Combine(Application.persistentDataPath, "beatsaber");
private void Start()
{
_audio = FindFirstObjectByType<AudioManager>();
_scoreManager = FindFirstObjectByType<ScoreManager>();
StartCoroutine(LoadAndPlay());
}
private void Update()
{
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
}
private IEnumerator LoadAndPlay()
{
SongInfo song = GameSession.SelectedSong;
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
}
clip = DownloadHandlerAudioClip.GetContent(req);
}
_clipLength = clip.length;
// Load and parse map
DifficultyInfo diffInfo = song.difficulties.Get(diff);
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
yield break;
}
map.target.Sort(CompareNotes);
_scoreManager?.SetTotalNotes(map.target.Count);
yield return StartCoroutine(Countdown());
_audio.PlayClip(clip);
StartCoroutine(SpawnRoutine(map.target));
yield return StartCoroutine(WaitForCompletion(clip.length));
yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
}
private IEnumerator Countdown()
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
foreach (NoteData note in notes)
{
float spawnAt = Mathf.Max(0f, note.time - travelTime);
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
SpawnNote(note);
}
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
float y = MapLayerY(note.lineLayer);
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
float remaining = note.time - _audio.CurrentTime;
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
float travelTime = Mathf.Max(0.05f, remaining);
var info = new SpawnEventInfo
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
position = new Vector3(x, y, 0f),
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
hitDirection = MapCutDirection(note.cutDirection),
useSpark = true,
useSpark = false,
speed = 2f,
travelTimeOverride = travelTime,
};
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
private static Direction MapCutDirection(int cut)
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
private IEnumerator WaitForCompletion(float clipLength)
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
{
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
yield return new WaitForSeconds(0.35f);
_scoreManager?.CompleteSong();
onLevelComplete?.Invoke();
}
}
+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 float maxDistance = 50f;
[SerializeField] private bool debugLogging = false;
[SerializeField] private float scrollSpeed = 2.4f;
[SerializeField] private float scrollDeadZone = 0.15f;
[SerializeField] private float dragScrollSpeed = 1.25f;
[SerializeField] private float dragClickThreshold = 0.025f;
private LineRenderer _line;
private bool _prevTrigger;
private Selectable _currentHover;
private ScrollRect _dragScrollRect;
private Selectable _triggerPressSelectable;
private Vector2 _dragStartLocalPoint;
private float _dragStartNormalizedPosition;
private float _dragMaxNormalizedDelta;
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
@@ -40,7 +50,8 @@ namespace VRBeats
private void 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()
@@ -56,11 +67,14 @@ namespace VRBeats
private void Update()
{
// 3초마다 연결된 디바이스 목록 출력
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
if (debugLogging)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
}
bool trigger = GetButton(CommonUsages.triggerButton);
@@ -70,31 +84,41 @@ namespace VRBeats
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick;
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
string hand = isRightHand ? "R" : "L";
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown)Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
if (debugLogging)
{
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward);
float hitDist = maxDistance;
float selectableHitDist = maxDistance;
float scrollHitDist = maxDistance;
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
bool beganScrollDrag = false;
if (triggerDown)
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
if (_dragScrollRect != null && trigger)
UpdateScrollDrag(ray);
else if (_dragScrollRect == null)
HandleScroll(scrollRect);
// 호버 변화 로그
if (hit != _currentHover)
if (debugLogging && hit != _currentHover)
{
Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}"
@@ -103,16 +127,21 @@ namespace VRBeats
UpdateHoverState(hit);
// 검지 트리거 또는 A/X 버튼으로 클릭
if (triggerDown || primaryDown)
if (triggerUp && _dragScrollRect != null)
EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{
if (_currentHover != null)
{
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover);
}
else
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
@@ -121,6 +150,12 @@ namespace VRBeats
}
DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
}
private void LogConnectedDevices()
@@ -222,6 +257,7 @@ namespace VRBeats
foreach (Selectable sel in Selectable.allSelectablesArray)
{
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue;
@@ -260,6 +296,189 @@ namespace VRBeats
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
}
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
{
ScrollRect closest = null;
float closestDist = maxDist;
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
foreach (ScrollRect scroll in all)
{
if (!scroll.isActiveAndEnabled) continue;
if (!IsOnEnabledCanvas(scroll)) continue;
RectTransform rt = scroll.viewport != null
? scroll.viewport
: scroll.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = scroll;
}
if (closest != null)
maxDist = closestDist;
return closest;
}
private static bool IsOnEnabledCanvas(Component component)
{
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
if (canvases.Length == 0)
return true;
for (int i = 0; i < canvases.Length; i++)
{
Canvas canvas = canvases[i];
if (canvas == null)
continue;
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
return false;
}
return true;
}
private void HandleScroll(ScrollRect scrollRect)
{
if (!CanScrollVertically(scrollRect))
return;
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
if (Mathf.Abs(axis.y) < scrollDeadZone)
return;
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
}
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
{
if (!CanScrollVertically(scrollRect))
return false;
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
return false;
_dragScrollRect = scrollRect;
_triggerPressSelectable = pressSelectable;
_dragStartLocalPoint = localPoint;
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
_dragMaxNormalizedDelta = 0f;
return true;
}
private void UpdateScrollDrag(Ray ray)
{
if (_dragScrollRect == null)
return;
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
return;
float deltaY = localPoint.y - _dragStartLocalPoint.y;
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
_dragStartNormalizedPosition - normalizedDelta);
}
private void EndScrollDrag(string hand, Ray ray)
{
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
ScrollRect scrollRect = _dragScrollRect;
Selectable pressSelectable = _triggerPressSelectable;
float startNormalizedPosition = _dragStartNormalizedPosition;
ClearScrollDrag();
if (!shouldClick)
return;
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
{
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
Click(pressSelectable);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(ray);
}
}
private void ClearScrollDrag()
{
_dragScrollRect = null;
_triggerPressSelectable = null;
_dragStartLocalPoint = Vector2.zero;
_dragStartNormalizedPosition = 0f;
_dragMaxNormalizedDelta = 0f;
}
private static bool CanScrollVertically(ScrollRect scrollRect)
{
if (scrollRect == null || !scrollRect.vertical)
return false;
RectTransform viewport = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (viewport == null || scrollRect.content == null)
return true;
return scrollRect.content.rect.height > viewport.rect.height + 1f;
}
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
{
localPoint = Vector2.zero;
viewportHeight = 1f;
RectTransform rt = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (rt == null)
return false;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
return false;
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
localPoint = new Vector2(local.x, local.y);
viewportHeight = Mathf.Max(1f, rt.rect.height);
return true;
}
private bool GetButton(InputFeatureUsage<bool> usage)
{
var chars = InputDeviceCharacteristics.Controller |
@@ -274,5 +493,20 @@ namespace VRBeats
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return Vector2.zero;
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
return axis;
}
}
}
+14 -1
View File
@@ -80,6 +80,9 @@ namespace VRBeats
if (!isRight && !isLeft) continue;
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
if (go.GetComponent<LineRenderer>() == null) continue;
DisableToolkitPointerComponents(go);
if (go.GetComponent<VRPointerController>() != null) continue;
var pointer = go.AddComponent<VRPointerController>();
@@ -94,8 +97,18 @@ namespace VRBeats
if (disabledByDefault)
pointer.enabled = false;
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
}
}
private static void DisableToolkitPointerComponents(GameObject go)
{
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
if (rayInteractor != null)
rayInteractor.enabled = false;
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
if (lineVisual != null)
lineVisual.enabled = false;
}
}
}
@@ -44,28 +44,6 @@ MonoBehaviour:
balance:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-8104416584915340131
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent2
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent2
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p21:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-7750755424749557576
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -239,19 +217,6 @@ MonoBehaviour:
maxNits:
m_OverrideState: 1
m_Value: 1000
--- !u!114 &-5360449096862653589
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedEverywhere
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedEverywhere
active: 1
--- !u!114 &-5139089513906902183
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -408,28 +373,6 @@ MonoBehaviour:
tint:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-581120513425526550
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent3
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent3
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p31:
m_OverrideState: 1
m_Value: {r: 0, g: 0, b: 0, a: 1}
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -852,19 +795,6 @@ MonoBehaviour:
intensity:
m_OverrideState: 1
m_Value: 0
--- !u!114 &6940869943325143175
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedOnAnySRP
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedOnAnySRP
active: 1
--- !u!114 &7173750748008157695
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -961,22 +891,3 @@ MonoBehaviour:
blueOutBlueIn:
m_OverrideState: 1
m_Value: 100
--- !u!114 &9122958982931076880
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent1
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent1
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
@@ -33,7 +33,8 @@ MonoBehaviour:
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
m_autoSizeTextContainer: 0
m_IsTextObjectScaleStatic: 0
m_fallbackFontAssets: []
m_fallbackFontAssets:
- {fileID: 11400000, guid: f6c6fe0f3c5912a43a8a6707e336d2ea, type: 2}
m_matchMaterialPreset: 1
m_HideSubTextObjects: 1
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
+168
View File
@@ -0,0 +1,168 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: SkyBox
m_Shader: {fileID: 108, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords:
- _MAPPING_LATITUDE_LONGITUDE_LAYOUT
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BackTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DownTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _FrontTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _LeftTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _RightTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _UpTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _Exposure: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _ImageType: 0
- _Layout: 0
- _Mapping: 1
- _Metallic: 0
- _MirrorOnBack: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Rotation: 0
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1}
- _Tint: {r: 0.5, g: 0.5, b: 0.5, a: 0.5}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &5645475041611047199
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fecd661b14876064fa838cbb52ca425e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -76,7 +76,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -170,7 +170,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -528,7 +528,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -760,7 +760,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -854,7 +854,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -1212,7 +1212,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -411,7 +411,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668446538128435}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1003,7 +1003,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668447105775893}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1295,7 +1295,7 @@ Camera:
m_GameObject: {fileID: 8546668447772986810}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
@@ -342,7 +342,7 @@ MonoBehaviour:
handSettings:
interactPoint: {fileID: 3074267110786978836}
highlightPoint: {fileID: 3074267110786978836}
rotationOffset: {x: 0, y: 90, z: 25}
rotationOffset: {x: 25, y: 0, z: 0}
canInteract: 1
rightHandAnimationSettings:
animation: {fileID: 0}
@@ -522,7 +522,7 @@ MonoBehaviour:
m_GameObject: {fileID: 5575416034875238503}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c68346ff56573a1429560a527ad447e0, type: 3}
m_Script: {fileID: 11500000, guid: 4de824eda67bd1c4ba4d379a9debd2b3, type: 3}
m_Name:
m_EditorClassIdentifier:
fastCollisionListener: {fileID: 5407220909436794986}
@@ -533,6 +533,7 @@ MonoBehaviour:
hitForce: 0
maxHitForce: 0
canDismember: 0
colorSide: 1
--- !u!114 &5407220909436794986
MonoBehaviour:
m_ObjectHideFlags: 0
+4 -4
View File
@@ -2639,7 +2639,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 661667650}
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 19.76}
m_LocalPosition: {x: 0, y: 0, z: 20.29}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2652,7 +2652,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 24.54, y: 4.415}
m_AnchoredPosition: {x: 24.01, y: 4.71}
m_SizeDelta: {x: 105.885, y: 71.226}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &661667652
@@ -7157,7 +7157,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1946485404}
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 17.9}
m_LocalPosition: {x: 0, y: 0, z: 18.33}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -7167,7 +7167,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -22.2, y: 4.39}
m_AnchoredPosition: {x: -21.77, y: 4.39}
m_SizeDelta: {x: 105.89, y: 66.53}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1946485406
@@ -39,6 +39,11 @@ namespace VRBeats
if (Cut(info.hitPoint, cutDir, insideMaterial))
{
Color trailColor = beatCube != null
? VR_BeatManager.instance.GetColorFromColorSide(beatCube.ThisColorSide)
: Color.white;
Vector3 saberUp = beatDamageInfo.hitObject != null ? beatDamageInfo.hitObject.transform.up : cutDir;
SliceTrailEffect.Spawn(info.hitPoint, info.hitDir, saberUp, trailColor);
Destroy(gameObject);
}
}
@@ -0,0 +1,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
if ( IsCutIntentValid(info as BeatDamageInfo) )
{
ScoreManager.ReportSliceTiming(GetTimingErrorSeconds());
onCorrectSlice.Invoke();
}
else
{
ScoreManager.ReportMiss();
onIncorrectSlice.Invoke();
}
@@ -98,6 +100,7 @@ namespace VRBeats
public void Kill()
{
ScoreManager.ReportMiss();
onPlayerMiss.Invoke();
canBeKilled = false;
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate
@@ -107,6 +110,13 @@ namespace VRBeats
} );
}
private float GetTimingErrorSeconds()
{
float speed = Mathf.Max(Mathf.Abs(thisSpawneable.Speed), 0.001f);
float distanceFromPlayer = Mathf.Abs(transform.position.z - player.position.z);
return distanceFromPlayer / speed;
}
}
@@ -1,6 +1,8 @@
using UnityEngine;
using System.Collections;
using UnityEngine;
using Platinio;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using VRBeats.ScriptableEvents;
using VRSDK;
@@ -60,24 +62,22 @@ namespace VRBeats
Vector3 finalPosition = CalculateSpawnPosition( info.position);
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
Vector3 spawnPosition = finalPosition - travelOffset;
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
SetSpeedRelativeToPlayZone(info);
clone.Construct(info);
Vector3 finalScale = clone.transform.localScale;
clone.transform.localScale = Vector3.zero;
float travelTime = info.travelTimeOverride > 0f ? info.travelTimeOverride : settings.TargetTravelTime;
clone.transform.Move(finalPosition, travelTime).SetEase(settings.TargetTravelEase).SetOnComplete(delegate
{
info.speed = settings.TargetTravelDistance / Mathf.Max(0.05f, travelTime);
SetSpeedRelativeToPlayZone(info);
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
clone.Construct(info);
StartCoroutine(BeginContinuousSpawnNextFrame(clone));
}
private IEnumerator BeginContinuousSpawnNextFrame(Spawneable clone)
{
yield return null;
if (clone != null)
clone.OnSpawn();
}).SetUpdateMode(Platinio.TweenEngine.UpdateMode.Update);
clone.transform.ScaleTween(finalScale, travelTime).SetEase(settings.TargetTravelEase);
}
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
@@ -122,13 +122,7 @@ namespace VRBeats
public void RestartLevel()
{
gameObject.CancelAllTweens();
isGameRunning = true;
audioManager.SetAudioMixerPitch(1.0f);
enviromentController.TurnLightsOn();
playableDirector.time = 0.0f;
playableDirector.Play();
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
@@ -39,9 +39,9 @@ namespace VRBeats
public void EnableXRRayInteractorComponents()
{
if (rayInteractor != null)
rayInteractor.enabled = true;
rayInteractor.enabled = false;
if (interactorLineVisual != null)
interactorLineVisual.enabled = true;
interactorLineVisual.enabled = false;
if (lineRender != null)
lineRender.enabled = true;
+12 -1
View File
@@ -15,10 +15,14 @@ namespace VRBeats
private VR_Grabbable grabbable = null;
private ColorSide colorSide = ColorSide.Left;
private MeshRenderer[] renderArray = null;
private SaberTrailEffect trailEffect = null;
private void Awake()
{
renderArray = transform.GetComponentsInChildren<MeshRenderer>();
trailEffect = GetComponent<SaberTrailEffect>();
if (trailEffect == null)
trailEffect = gameObject.AddComponent<SaberTrailEffect>();
grabbable = GetComponent<VR_Grabbable>();
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
@@ -44,6 +48,9 @@ namespace VRBeats
{
SetMaterialBindings(materialBindingArray[n], c);
}
if (trailEffect != null)
trailEffect.SetColor(c);
}
private void SetMaterialBindings(MaterialBindings matBindings, Color c)
@@ -55,11 +62,15 @@ namespace VRBeats
public void MakeVisible()
{
SetRenderArrayEnableValue(true);
if (trailEffect != null)
trailEffect.SetVisible(true);
}
public void MakeInvisible()
{
SetRenderArrayEnableValue(false);
if (trailEffect != null)
trailEffect.SetVisible(false);
}
private void SetRenderArrayEnableValue(bool value)
@@ -72,4 +83,4 @@ namespace VRBeats
}
}
}
@@ -13,24 +13,42 @@ namespace VRBeats
private void Start()
{
if (colorSide == ColorSide.Left) controller = VR_Manager.instance.Player.LeftController;
if (colorSide == ColorSide.Right) controller = VR_Manager.instance.Player.RightController;
ResolveController();
}
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
{
ResolveController();
var damageInfo = base.CreateDamageInfo(hitPoint);
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo);
Vector3 controllerVelocity = controller.Velocity;
Vector3 controllerVelocity = controller != null ? controller.Velocity : Vector3.zero;
beatDamageInfo.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
beatDamageInfo.hitObject = gameObject;
beatDamageInfo.colorSide = colorSide;
beatDamageInfo.velocity = controller.Velocity.magnitude;
beatDamageInfo.velocity = controllerVelocity.magnitude;
return beatDamageInfo;
}
private void ResolveController()
{
VR_Grabbable grabbable = GetComponent<VR_Grabbable>();
if (grabbable != null && grabbable.GrabController != null)
{
controller = grabbable.GrabController;
colorSide = controller.ControllerType == VR_ControllerType.Right ? ColorSide.Right : ColorSide.Left;
return;
}
if (VR_Manager.instance == null || VR_Manager.instance.Player == null)
return;
controller = colorSide == ColorSide.Left
? VR_Manager.instance.Player.LeftController
: VR_Manager.instance.Player.RightController;
}
}
}
@@ -32,7 +32,7 @@ namespace VRBeats
public Vector3 rotation = Vector3.zero;
public float speed = 2.0f;
public int speedMultiplier = 1;
// 0 이면 Settings.TargetTravelTime 사용, 양수면 해당 시간로 이동
// 0이면 Settings.TargetTravelTime 사용, 양수면 해당 시간 동안 일정 속도로 이동
public float travelTimeOverride = 0f;
}
}
@@ -21,21 +21,30 @@ namespace VRBeats
}
scoreManager = FindFirstObjectByType<ScoreManager>();
ApplyPopupTextStyle();
}
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 );
}).SetOnComplete(delegate
{
scoreText.text = scoreManager.BuildResultSummary(length);
});
}
public void ResetValues()
{
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 Platinio.TweenEngine;
using VRBeats.ScriptableEvents;
@@ -7,6 +7,14 @@ namespace VRBeats
{
public class ScoreManager : MonoBehaviour
{
private enum BeatJudgement
{
Perfect,
Great,
Good,
Miss
}
[SerializeField] private Text multiplierLabel = null;
[SerializeField] private Text scoreLabel = null;
[SerializeField] private Image multiplierLoader = null;
@@ -14,61 +22,167 @@ namespace VRBeats
[SerializeField] private CanvasGroup canvasGroup = null;
[SerializeField] private GameEvent onGameOver = null;
[Header("DJMAX Style Score")]
[SerializeField] private Text comboLabel = null;
[SerializeField] private Text accuracyLabel = null;
[SerializeField] private Text judgementLabel = null;
[SerializeField] private bool createMissingHudLabels = true;
[SerializeField] private bool applyHudPlacement = true;
[SerializeField] private Vector2 hudAnchoredPosition = new Vector2(0.0f, 1.65f);
[SerializeField] private float perfectWindow = 0.08f;
[SerializeField] private float greatWindow = 0.15f;
[SerializeField] private float goodWindow = 0.25f;
private int maxMultiplier = 0;
private int scorePerHit = 0;
private int currentScore = 0;
private int currentMultiplier = 0;
private int toNextMultiplierIncrease = 2;
private int acumulateCorrectSlices = 0;
private const int MaxCourseScore = 1000000;
private float currentMultiplier = 1.0f;
private int acumulateErrors = 0;
private int errorLimit = 0;
private int totalNoteCount = 0;
private int judgedNoteCount = 0;
private int currentCombo = 0;
private int maxCombo = 0;
private int perfectCount = 0;
private int greatCount = 0;
private int goodCount = 0;
private int missCount = 0;
private int earnedAccuracyPoints = 0;
private float visualScore = 0.0f;
private int scoreTweenID = -1;
private int loaderTweenID = -1;
private BeatJudgement lastJudgement = BeatJudgement.Perfect;
private float judgementTimer = 0.0f;
private Text progressLabel = null;
private Text rankLabel = null;
private Vector3 comboBaseScale = Vector3.one;
private float songCurrentTime = 0.0f;
private float songDuration = 0.0f;
private bool resultFinalized = 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
{
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()
{
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
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()
{
{
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()
{
{
ResetThisComponent();
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()
{
currentMultiplier = 0;
currentScore = 0;
acumulateCorrectSlices = 0;
{
currentMultiplier = 1.0f;
visualScore = 0;
acumulateErrors = 0;
toNextMultiplierIncrease = 2;
judgedNoteCount = 0;
currentCombo = 0;
maxCombo = 0;
perfectCount = 0;
greatCount = 0;
goodCount = 0;
missCount = 0;
earnedAccuracyPoints = 0;
judgementTimer = 0.0f;
resultFinalized = false;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
}
private void Update()
{
UpdateUI();
@@ -79,46 +193,13 @@ namespace VRBeats
if (destroyed)
return;
acumulateErrors = 0;
acumulateCorrectSlices++;
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
BeatJudgement judgement = ConsumeJudgement();
RegisterJudgement(judgement);
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
visualScore = value;
}).ID;
acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateCorrectSlices >= toNextMultiplierIncrease)
{
IncreaseMultiplier();
}
}
private void CancelTweenById(int id)
{
if(id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed)
return;
float multiplierLoaderValue = (float)acumulateCorrectSlices / (float)toNextMultiplierIncrease;
CancelTweenById(loaderTweenID);
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).ID;
}
public void OnIncorrectSlice()
@@ -126,18 +207,63 @@ namespace VRBeats
if (destroyed)
return;
RegisterJudgement(BeatJudgement.Miss);
acumulateErrors++;
acumulateCorrectSlices = 0;
currentMultiplier = 0;
toNextMultiplierIncrease = 2;
currentMultiplier = 1.0f;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateErrors > errorLimit)
{
onGameOver.Invoke();
}
}
public string BuildResultSummary(int minScoreLength)
{
string score = CurrentScore.ToString();
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()
@@ -145,41 +271,230 @@ namespace VRBeats
if (destroyed)
return;
multiplierLabel.text = currentMultiplier.ToString();
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString();
}
if (multiplierLabel != null)
multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}";
if (scoreLabel != null)
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
if (comboLabel != null)
comboLabel.text = currentCombo > 0
? $"<size=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 (destroyed)
if (judgementLabel == null)
return;
acumulateCorrectSlices = 0;
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier );
toNextMultiplierIncrease = (currentMultiplier + 1) * 2;
PlatinioTween.instance.CancelTween(multiplierLoader.gameObject);
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 1.0f, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).SetOnComplete( delegate
{
if (multiplierLabel != null)
{
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject);
}
} );
judgementTimer -= Time.deltaTime;
judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : "";
judgementLabel.color = GetJudgementColor(lastJudgement);
}
private BeatJudgement ConsumeJudgement()
{
if (!hasPendingSliceTiming)
return BeatJudgement.Perfect;
float timing = pendingSliceTiming;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (timing <= perfectWindow) return BeatJudgement.Perfect;
if (timing <= greatWindow) return BeatJudgement.Great;
if (timing <= goodWindow) return BeatJudgement.Good;
return BeatJudgement.Good;
}
private void RegisterJudgement(BeatJudgement judgement)
{
lastJudgement = judgement;
judgementTimer = 0.45f;
judgedNoteCount++;
if (judgement == BeatJudgement.Perfect)
{
perfectCount++;
earnedAccuracyPoints += 1000;
currentCombo++;
}
else if (judgement == BeatJudgement.Great)
{
greatCount++;
earnedAccuracyPoints += 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:
rightColor: {r: 0, g: 0.6002884, b: 1, a: 1}
leftColor: {r: 1, g: 0, b: 0, a: 1}
glowIntensity: 100
glowIntensity: 40
targetTravelDistance: 40
targetTravelTime: 1.8
targetTravelTime: 3.2
targetTravelEase: 19
errorLimit: 7
scorePerHit: 50
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: f47e26c36f77476ba803b5158d1b30da
VideoClipImporter:
externalObjects: {}
serializedVersion: 3
frameRange: 0
startFrame: -1
endFrame: -1
colorSpace: 0
deinterlace: 0
encodeAlpha: 0
flipVertical: 0
flipHorizontal: 0
importAudio: 1
targetSettings: {}
userData:
assetBundleName:
assetBundleVariant:
+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`
- 현재 브랜치: `main`
- 현재 브랜치: `master`
- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git`
- 최근 푸시 커밋: `182d2c9 fix: stabilize VR UI and song playback`
- `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에 업로드
```
### 최근 반영된 변경
### 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`
- Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다.
@@ -94,13 +163,13 @@ SongCreator.unity
### 현재 주의사항
1. `Assets/StreamingAssets/nas_config.json`은 현재 저장소에 없다. NAS 업로드 테스트하려면 로컬에 직접 만들어야 하며, 절대 커밋하지 않는다.
2. `SongCreator.unity`의 직렬화된 `nasBaseUrl` 값에 끝 공백이 들어가 있다: `http://whdwo798.synology.me:5000 `. 런타임에서 `nas_config.json`으로 덮어쓰지 않으면 로그인 URL 문제가 날 수 있다.
3. `SongCreatorManager`는 난이도 토글 필드를 갖고 있지만 현재 로직은 4개 난이도(`normal`, `hard`, `expert`, `expertplus`)를 항상 전부 생성한다.
4. `manualEditorButton`은 씬에서 미연결이고 코드에서도 사용하지 않는다.
5. `Assets/img/360.mp4`는 약 192MB다. 현재 일반 Git으로 푸시되어 있으므로, 원격 정책이 바뀌면 Git LFS 전환을 검토해야 한다.
6. 큐브 간격은 수치상 겹침을 피하도록 넓혔지만, 실제 Quest 착용 테스트에서 손 위치/판정 거리/시야 피로도를 확인해야 한다.
7. SongCreator에서 생성 직후 첫 재생이 곡에 따라 늦거나 싱크가 흔들리는 체감이 있었다. 게임 씬 오디오 기준은 `AudioSettings.dspTime`으로 개선했지만, 생성/다운로드/첫 로드 전체 파이프라인은 추가 로그 검증이 필요하다.
1. `Assets/StreamingAssets/nas_config.json`과 루트 `env`는 로컬 전용이다. NAS 업로드 테스트 전 계정/비밀번호를 직접 넣되 절대 커밋하지 않는다.
2. Unity 콘솔의 `Unable to start Oculus XR Plugin`은 헤드셋/오큘러스 런타임 상태 문제일 수 있다. PC 에디터 단독 실행에서는 경고가 날 수 있다.
3. `The referenced script (Unknown) on this Behaviour is missing!` 경고가 남는 씬/프리팹은 추가 확인 대상이다. 게임 진행을 막는 직접 원인은 아니지만, 인스펙터에서 Missing Script를 정리하는 것이 좋다.
4. 영상 배경은 Unity 호환 MP4가 안전하다. H.264 timestamp warning이 뜨는 원본은 baseline profile, CFR, yuv420p로 재인코딩한다.
5. `Assets/img` 아래 영상 파일은 크기가 커질 수 있다. 원격 저장소 정책에 걸리면 Git LFS 전환을 검토한다.
6. 싱크 보정 화면은 UI/입력 구조까지 들어갔지만, 기기별 오디오 지연 값은 Quest 실기에서 측정해야 한다.
7. 세이버 잔상, 큐브 이동 속도, 블룸 강도는 체감 튜닝 항목이다. 현재 값은 빌드/컴파일 기준으로 안전하지만, VR 착용 테스트 후 수치를 조정하는 것이 좋다.
---
+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"
}
}