feat: polish VR gameplay and sync tools
This commit is contained in:
+16
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2e8c518ec2f4a03a6d820774b475ce0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
@@ -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
@@ -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 착용 테스트 후 수치를 조정하는 것이 좋다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
Generated
+1162
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user