feat: polish VR gameplay and sync tools
This commit is contained in:
+16
@@ -20,5 +20,21 @@
|
|||||||
|
|
||||||
# Credentials — never commit
|
# Credentials — never commit
|
||||||
.env
|
.env
|
||||||
|
/env
|
||||||
|
/cookies.txt
|
||||||
/Assets/StreamingAssets/nas_config.json
|
/Assets/StreamingAssets/nas_config.json
|
||||||
/Assets/StreamingAssets/nas_config.json.meta
|
/Assets/StreamingAssets/nas_config.json.meta
|
||||||
|
|
||||||
|
# Local tool output
|
||||||
|
/Captures/
|
||||||
|
/tools/unity-mcp-server/node_modules/
|
||||||
|
/Assets/_Recovery/
|
||||||
|
/Assets/_Recovery.meta
|
||||||
|
|
||||||
|
# Local video sources / superseded test clips
|
||||||
|
/Assets/img/*.mkv
|
||||||
|
/Assets/img/*.mkv.meta
|
||||||
|
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4
|
||||||
|
/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4.meta
|
||||||
|
/Assets/img/neon_background_unity.mp4
|
||||||
|
/Assets/img/neon_background_unity.mp4.meta
|
||||||
|
|||||||
@@ -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,
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.handSelected
|
propertyPath: editorPart.handSelected
|
||||||
value: 1
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.selectedMenu
|
propertyPath: editorPart.selectedMenu
|
||||||
value: 1
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: handSettings.rotationOffset.x
|
||||||
|
value: 45
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: colorSide
|
propertyPath: colorSide
|
||||||
@@ -627,12 +632,12 @@ PrefabInstance:
|
|||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.w
|
propertyPath: m_LocalRotation.w
|
||||||
value: 0.9239
|
value: 0.7071068
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.x
|
propertyPath: m_LocalRotation.x
|
||||||
value: 0.3827
|
value: 0.7071068
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
@@ -647,7 +652,7 @@ PrefabInstance:
|
|||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalEulerAnglesHint.x
|
propertyPath: m_LocalEulerAnglesHint.x
|
||||||
value: 45
|
value: 90
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
@@ -1263,7 +1268,7 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
m_AnchorMin: {x: 0, y: 0}
|
m_AnchorMin: {x: 0, y: 0}
|
||||||
m_AnchorMax: {x: 0, y: 0}
|
m_AnchorMax: {x: 0, y: 0}
|
||||||
m_AnchoredPosition: {x: -7.5, y: -1.7}
|
m_AnchoredPosition: {x: 5.8, y: 2.4}
|
||||||
m_SizeDelta: {x: 847.5, y: 1141.086}
|
m_SizeDelta: {x: 847.5, y: 1141.086}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!114 &454873730
|
--- !u!114 &454873730
|
||||||
@@ -1284,6 +1289,15 @@ MonoBehaviour:
|
|||||||
scoreFollowTime: 1
|
scoreFollowTime: 1
|
||||||
canvasGroup: {fileID: 454873732}
|
canvasGroup: {fileID: 454873732}
|
||||||
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
|
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
|
||||||
|
comboLabel: {fileID: 0}
|
||||||
|
accuracyLabel: {fileID: 0}
|
||||||
|
judgementLabel: {fileID: 0}
|
||||||
|
createMissingHudLabels: 1
|
||||||
|
applyHudPlacement: 1
|
||||||
|
hudAnchoredPosition: {x: 5.8, y: 2.4}
|
||||||
|
perfectWindow: 0.08
|
||||||
|
greatWindow: 0.15
|
||||||
|
goodWindow: 0.25
|
||||||
--- !u!114 &454873731
|
--- !u!114 &454873731
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -2380,7 +2394,7 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
m_destroyOnLoad: 1
|
m_destroyOnLoad: 1
|
||||||
currentSDK: 0
|
currentSDK: 3
|
||||||
gestureConfig:
|
gestureConfig:
|
||||||
minAcelerationThreshold: 15
|
minAcelerationThreshold: 15
|
||||||
maxAcelerationThreshold: 40
|
maxAcelerationThreshold: 40
|
||||||
@@ -2652,8 +2666,8 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
m_AnchorMin: {x: 0, y: 0.5}
|
m_AnchorMin: {x: 0, y: 0.5}
|
||||||
m_AnchorMax: {x: 1, y: 0.5}
|
m_AnchorMax: {x: 1, y: 0.5}
|
||||||
m_AnchoredPosition: {x: -1.4000015, y: 2.5099983}
|
m_AnchoredPosition: {x: 0, y: -0.45}
|
||||||
m_SizeDelta: {x: 20.150002, y: 6.41}
|
m_SizeDelta: {x: 18.8, y: 13.2}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!1 &1209841520
|
--- !u!1 &1209841520
|
||||||
GameObject:
|
GameObject:
|
||||||
@@ -2692,7 +2706,7 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||||
m_AnchoredPosition: {x: -16.119999, y: -15.380001}
|
m_AnchoredPosition: {x: -17.400002, y: -15.380001}
|
||||||
m_SizeDelta: {x: 357, y: 157}
|
m_SizeDelta: {x: 357, y: 157}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!114 &1209841522
|
--- !u!114 &1209841522
|
||||||
@@ -2825,7 +2839,6 @@ MonoBehaviour:
|
|||||||
playZone: {fileID: 778115775}
|
playZone: {fileID: 778115775}
|
||||||
player: {fileID: 408071456}
|
player: {fileID: 408071456}
|
||||||
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
|
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
|
||||||
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
|
|
||||||
--- !u!4 &1215359035
|
--- !u!4 &1215359035
|
||||||
Transform:
|
Transform:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -3790,8 +3803,8 @@ MonoBehaviour:
|
|||||||
m_faceColor:
|
m_faceColor:
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
rgba: 4294967295
|
rgba: 4294967295
|
||||||
m_fontSize: 7.1
|
m_fontSize: 4.4
|
||||||
m_fontSizeBase: 7.1
|
m_fontSizeBase: 4.4
|
||||||
m_fontWeight: 400
|
m_fontWeight: 400
|
||||||
m_enableAutoSizing: 0
|
m_enableAutoSizing: 0
|
||||||
m_fontSizeMin: 18
|
m_fontSizeMin: 18
|
||||||
@@ -3803,7 +3816,7 @@ MonoBehaviour:
|
|||||||
m_characterSpacing: 0
|
m_characterSpacing: 0
|
||||||
m_characterHorizontalScale: 1
|
m_characterHorizontalScale: 1
|
||||||
m_wordSpacing: 0
|
m_wordSpacing: 0
|
||||||
m_lineSpacing: 0
|
m_lineSpacing: -18
|
||||||
m_lineSpacingMax: 0
|
m_lineSpacingMax: 0
|
||||||
m_paragraphSpacing: 0
|
m_paragraphSpacing: 0
|
||||||
m_charWidthMaxAdj: 0
|
m_charWidthMaxAdj: 0
|
||||||
@@ -4376,6 +4389,7 @@ GameObject:
|
|||||||
m_Component:
|
m_Component:
|
||||||
- component: {fileID: 1958381893}
|
- component: {fileID: 1958381893}
|
||||||
- component: {fileID: 1958381892}
|
- component: {fileID: 1958381892}
|
||||||
|
- component: {fileID: 1958381894}
|
||||||
m_Layer: 0
|
m_Layer: 0
|
||||||
m_Name: SongController
|
m_Name: SongController
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
@@ -4414,6 +4428,23 @@ Transform:
|
|||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!114 &1958381894
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1958381891}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 3e381cd99de84f67b9f83c19a032dc24, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::Game360VideoBackground
|
||||||
|
videoClip: {fileID: 32900000, guid: f47e26c36f77476ba803b5158d1b30da, type: 3}
|
||||||
|
renderTextureSize: 2048
|
||||||
|
muteVideoAudio: 1
|
||||||
|
skyboxRotationDegrees: 90
|
||||||
|
skyboxExposure: 1
|
||||||
--- !u!4 &2043306906 stripped
|
--- !u!4 &2043306906 stripped
|
||||||
Transform:
|
Transform:
|
||||||
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
|
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
@@ -4457,7 +4488,7 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||||
m_AnchoredPosition: {x: 15.980001, y: -15.380001}
|
m_AnchoredPosition: {x: 17.400002, y: -15.380001}
|
||||||
m_SizeDelta: {x: 357, y: 157}
|
m_SizeDelta: {x: 357, y: 157}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!114 &2094521063
|
--- !u!114 &2094521063
|
||||||
@@ -4857,6 +4888,11 @@ PrefabInstance:
|
|||||||
propertyPath: startOnRightController
|
propertyPath: startOnRightController
|
||||||
value: 1
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: editorPart.foldoutBasic
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.foldoutInput
|
propertyPath: editorPart.foldoutInput
|
||||||
@@ -4865,13 +4901,23 @@ PrefabInstance:
|
|||||||
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.handSelected
|
propertyPath: editorPart.handSelected
|
||||||
value: 1
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.selectedMenu
|
propertyPath: editorPart.selectedMenu
|
||||||
value: 1
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: shareHandInteractionSettings
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: editorPart.foldoutInteraction
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
- target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_RootOrder
|
propertyPath: m_RootOrder
|
||||||
@@ -5157,6 +5203,11 @@ PrefabInstance:
|
|||||||
propertyPath: startOnRightController
|
propertyPath: startOnRightController
|
||||||
value: 0
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: editorPart.foldoutBasic
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.foldoutInput
|
propertyPath: editorPart.foldoutInput
|
||||||
@@ -5165,13 +5216,23 @@ PrefabInstance:
|
|||||||
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.handSelected
|
propertyPath: editorPart.handSelected
|
||||||
value: 0
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.selectedMenu
|
propertyPath: editorPart.selectedMenu
|
||||||
value: 1
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: shareHandInteractionSettings
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: editorPart.foldoutInteraction
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
|
- target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_Name
|
propertyPath: m_Name
|
||||||
@@ -5277,7 +5338,7 @@ PrefabInstance:
|
|||||||
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: editorPart.handSelected
|
propertyPath: editorPart.handSelected
|
||||||
value: 0
|
value: 1
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
@@ -5289,6 +5350,11 @@ PrefabInstance:
|
|||||||
propertyPath: startOnRightcController
|
propertyPath: startOnRightcController
|
||||||
value: 0
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
|
type: 3}
|
||||||
|
propertyPath: handSettings.rotationOffset.x
|
||||||
|
value: 45
|
||||||
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: controller
|
propertyPath: controller
|
||||||
@@ -5317,27 +5383,27 @@ PrefabInstance:
|
|||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.w
|
propertyPath: m_LocalRotation.w
|
||||||
value: 0.99958926
|
value: 0.7071068
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.x
|
propertyPath: m_LocalRotation.x
|
||||||
value: -0
|
value: 0.7071068
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.y
|
propertyPath: m_LocalRotation.y
|
||||||
value: -0.028659718
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalRotation.z
|
propertyPath: m_LocalRotation.z
|
||||||
value: -0
|
value: 0
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
propertyPath: m_LocalEulerAnglesHint.x
|
propertyPath: m_LocalEulerAnglesHint.x
|
||||||
value: 0
|
value: 90
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
|
||||||
type: 3}
|
type: 3}
|
||||||
|
|||||||
@@ -8107,7 +8107,7 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
|
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
|
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
|
||||||
nasBaseUrl: 'http://whdwo798.synology.me:5000 '
|
nasBaseUrl: http://whdwo798.synology.me:5000
|
||||||
nasAccount: beatSaber_app
|
nasAccount: beatSaber_app
|
||||||
nasRootPath: /web/beatsaber
|
nasRootPath: /web/beatsaber
|
||||||
staticBaseUrl: http://whdwo798.synology.me/beatsaber
|
staticBaseUrl: http://whdwo798.synology.me/beatsaber
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class BeatSageNote
|
|||||||
|
|
||||||
public static class BeatSageConverter
|
public static class BeatSageConverter
|
||||||
{
|
{
|
||||||
|
private static readonly bool LogConversions = false;
|
||||||
|
|
||||||
public static List<NoteData> Convert(string rawJson, float bpm)
|
public static List<NoteData> Convert(string rawJson, float bpm)
|
||||||
{
|
{
|
||||||
var result = new List<NoteData>();
|
var result = new List<NoteData>();
|
||||||
@@ -62,6 +64,7 @@ public static class BeatSageConverter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LogConversions)
|
||||||
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
|
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
|
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||||
|
|
||||||
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
|
||||||
|
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
|
|||||||
Directory.Delete(dir, recursive: true);
|
Directory.Delete(dir, recursive: true);
|
||||||
Debug.Log($"[DownloadManager] 삭제: {songId}");
|
Debug.Log($"[DownloadManager] 삭제: {songId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string legacyDir = LegacySongDir(songId);
|
||||||
|
if (Directory.Exists(legacyDir))
|
||||||
|
Directory.Delete(legacyDir, recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteDifficulty(SongInfo song, string difficulty)
|
public void DeleteDifficulty(SongInfo song, string difficulty)
|
||||||
{
|
{
|
||||||
|
TryMigrateLegacySong(song.id);
|
||||||
|
|
||||||
string path = MapPath(song, difficulty);
|
string path = MapPath(song, difficulty);
|
||||||
if (path != null && File.Exists(path))
|
if (path != null && File.Exists(path))
|
||||||
File.Delete(path);
|
File.Delete(path);
|
||||||
|
|
||||||
|
string songDir = SongDir(song.id);
|
||||||
|
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
|
||||||
|
Directory.Delete(songDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSongDownloaded(string songId)
|
public bool IsSongDownloaded(string songId)
|
||||||
=> File.Exists(AudioPath(songId));
|
{
|
||||||
|
TryMigrateLegacySong(songId);
|
||||||
|
return File.Exists(AudioPath(songId));
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
|
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
|
||||||
{
|
{
|
||||||
|
TryMigrateLegacySong(song.id);
|
||||||
|
|
||||||
string path = MapPath(song, difficulty);
|
string path = MapPath(song, difficulty);
|
||||||
return path != null && File.Exists(path);
|
return path != null && File.Exists(path);
|
||||||
}
|
}
|
||||||
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
|
|||||||
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
||||||
Action<float> onProgress, Action onComplete, Action<string> onError)
|
Action<float> onProgress, Action onComplete, Action<string> onError)
|
||||||
{
|
{
|
||||||
|
TryMigrateLegacySong(song.id);
|
||||||
|
|
||||||
string songDir = Path.GetFullPath(SongDir(song.id));
|
string songDir = Path.GetFullPath(SongDir(song.id));
|
||||||
Directory.CreateDirectory(songDir);
|
Directory.CreateDirectory(songDir);
|
||||||
|
|
||||||
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
|
|||||||
|
|
||||||
private static string SongDir(string songId)
|
private static string SongDir(string songId)
|
||||||
=> Path.Combine(CacheRoot, songId);
|
=> Path.Combine(CacheRoot, songId);
|
||||||
|
|
||||||
|
private static string LegacySongDir(string songId)
|
||||||
|
=> Path.Combine(LegacyCacheRoot, songId);
|
||||||
|
|
||||||
|
private static void TryMigrateLegacySong(string songId)
|
||||||
|
{
|
||||||
|
string sourceDir = LegacySongDir(songId);
|
||||||
|
string targetDir = SongDir(songId);
|
||||||
|
|
||||||
|
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
CopyDirectory(sourceDir, targetDir);
|
||||||
|
Directory.Delete(sourceDir, recursive: true);
|
||||||
|
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyDirectory(string sourceDir, string targetDir)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
|
||||||
|
foreach (string file in Directory.GetFiles(sourceDir))
|
||||||
|
{
|
||||||
|
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
|
||||||
|
File.Copy(file, targetFile, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string dir in Directory.GetDirectories(sourceDir))
|
||||||
|
{
|
||||||
|
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
|
||||||
|
CopyDirectory(dir, targetSubDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Networking;
|
using UnityEngine.Networking;
|
||||||
|
|
||||||
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
|
|||||||
private void LoadConfig()
|
private void LoadConfig()
|
||||||
{
|
{
|
||||||
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
||||||
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
||||||
if (cfg == null) return;
|
if (cfg == null) return;
|
||||||
_password = cfg.password ?? "";
|
_password = cfg.password ?? "";
|
||||||
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
|
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
|
||||||
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
|
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
|
||||||
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
|
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
|
||||||
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
|
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
|
||||||
|
|
||||||
|
NormalizeSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable] private class NasConfig
|
[Serializable] private class NasConfig
|
||||||
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
|
|||||||
Action onComplete,
|
Action onComplete,
|
||||||
Action<string> onError)
|
Action<string> onError)
|
||||||
{
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
|
||||||
bool failed = false;
|
bool failed = false;
|
||||||
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
||||||
|
|
||||||
@@ -92,13 +101,32 @@ public class NasPublisher : MonoBehaviour
|
|||||||
|
|
||||||
private IEnumerator Login(Action<string> onError)
|
private IEnumerator Login(Action<string> onError)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_password))
|
||||||
|
{
|
||||||
|
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||||
$"?api=SYNO.API.Auth&version=6&method=login" +
|
$"?api=SYNO.API.Auth&version=6&method=login" +
|
||||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||||
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
||||||
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
||||||
|
|
||||||
using var req = UnityWebRequest.Get(url);
|
UnityWebRequest req;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
req = UnityWebRequest.Get(url);
|
||||||
|
}
|
||||||
|
catch (UriFormatException e)
|
||||||
|
{
|
||||||
|
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string resp;
|
||||||
|
using (req)
|
||||||
|
{
|
||||||
yield return req.SendWebRequest();
|
yield return req.SendWebRequest();
|
||||||
|
|
||||||
if (req.result != UnityWebRequest.Result.Success)
|
if (req.result != UnityWebRequest.Result.Success)
|
||||||
@@ -107,16 +135,19 @@ public class NasPublisher : MonoBehaviour
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
string resp = req.downloadHandler.text;
|
resp = req.downloadHandler.text;
|
||||||
_sid = ParseJsonString(resp, "sid");
|
_sid = ParseJsonString(resp, "sid");
|
||||||
_synoToken = ParseJsonString(resp, "synotoken");
|
_synoToken = ParseJsonString(resp, "synotoken");
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(_sid))
|
if (string.IsNullOrEmpty(_sid))
|
||||||
onError?.Invoke("DSM sid parse failed — check credentials.");
|
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerator Logout()
|
private IEnumerator Logout()
|
||||||
{
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
|
||||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||||
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
|
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
|
||||||
using var req = UnityWebRequest.Get(url);
|
using var req = UnityWebRequest.Get(url);
|
||||||
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
|
|||||||
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
||||||
string nasFolder, Action<string> onError)
|
string nasFolder, Action<string> onError)
|
||||||
{
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
|
||||||
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
||||||
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
||||||
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
||||||
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
|
|||||||
|
|
||||||
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
|
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
|
||||||
{
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
|
||||||
SongsList list = null;
|
SongsList list = null;
|
||||||
|
|
||||||
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
|
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
|
||||||
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
|
|||||||
|
|
||||||
private static string ParseJsonString(string json, string key)
|
private static string ParseJsonString(string json, string key)
|
||||||
{
|
{
|
||||||
string search = $"\"{key}\":\"";
|
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
|
||||||
int start = json.IndexOf(search, StringComparison.Ordinal);
|
return null;
|
||||||
if (start < 0) return null;
|
|
||||||
start += search.Length;
|
Match match = Regex.Match(
|
||||||
int end = json.IndexOf('"', start);
|
json,
|
||||||
return end > start ? json.Substring(start, end - start) : null;
|
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
|
||||||
|
|
||||||
|
return match.Success
|
||||||
|
? Regex.Unescape(match.Groups["value"].Value)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssignMapFile(SongInfo song, string diff, string fileName)
|
private static void AssignMapFile(SongInfo song, string diff, string fileName)
|
||||||
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
|
|||||||
var info = song.difficulties.Get(diff);
|
var info = song.difficulties.Get(diff);
|
||||||
if (info != null) info.mapFile = $"maps/{fileName}";
|
if (info != null) info.mapFile = $"maps/{fileName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
NormalizeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NormalizeSettings()
|
||||||
|
{
|
||||||
|
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
|
||||||
|
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
|
||||||
|
nasAccount = nasAccount?.Trim() ?? "";
|
||||||
|
nasRootPath = NormalizeRootPath(nasRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeBaseUrl(string value)
|
||||||
|
{
|
||||||
|
return (value ?? "").Trim().TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRootPath(string value)
|
||||||
|
{
|
||||||
|
value = (value ?? "").Trim().Replace('\\', '/');
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
return "/";
|
||||||
|
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Shorten(string value, int maxLength = 240)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||||
|
return value ?? "";
|
||||||
|
|
||||||
|
return value.Substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
|
|||||||
private const float VerticalCenter = 1f;
|
private const float VerticalCenter = 1f;
|
||||||
|
|
||||||
private AudioManager _audio;
|
private AudioManager _audio;
|
||||||
|
private ScoreManager _scoreManager;
|
||||||
|
private float _clipLength;
|
||||||
|
|
||||||
private static string CacheRoot =>
|
private static string CacheRoot =>
|
||||||
Path.Combine(Application.temporaryCachePath, "beatsaber");
|
Path.Combine(Application.persistentDataPath, "beatsaber");
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
_audio = FindFirstObjectByType<AudioManager>();
|
_audio = FindFirstObjectByType<AudioManager>();
|
||||||
|
_scoreManager = FindFirstObjectByType<ScoreManager>();
|
||||||
StartCoroutine(LoadAndPlay());
|
StartCoroutine(LoadAndPlay());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
|
||||||
|
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
|
||||||
|
}
|
||||||
|
|
||||||
private IEnumerator LoadAndPlay()
|
private IEnumerator LoadAndPlay()
|
||||||
{
|
{
|
||||||
SongInfo song = GameSession.SelectedSong;
|
SongInfo song = GameSession.SelectedSong;
|
||||||
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
|
|||||||
}
|
}
|
||||||
clip = DownloadHandlerAudioClip.GetContent(req);
|
clip = DownloadHandlerAudioClip.GetContent(req);
|
||||||
}
|
}
|
||||||
|
_clipLength = clip.length;
|
||||||
|
|
||||||
// Load and parse map
|
// Load and parse map
|
||||||
DifficultyInfo diffInfo = song.difficulties.Get(diff);
|
DifficultyInfo diffInfo = song.difficulties.Get(diff);
|
||||||
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
map.target.Sort(CompareNotes);
|
map.target.Sort(CompareNotes);
|
||||||
|
_scoreManager?.SetTotalNotes(map.target.Count);
|
||||||
|
|
||||||
yield return StartCoroutine(Countdown());
|
yield return StartCoroutine(Countdown());
|
||||||
|
|
||||||
_audio.PlayClip(clip);
|
_audio.PlayClip(clip);
|
||||||
|
|
||||||
StartCoroutine(SpawnRoutine(map.target));
|
StartCoroutine(SpawnRoutine(map.target));
|
||||||
yield return StartCoroutine(WaitForCompletion(clip.length));
|
yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerator Countdown()
|
private IEnumerator Countdown()
|
||||||
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
|
|||||||
|
|
||||||
foreach (NoteData note in notes)
|
foreach (NoteData note in notes)
|
||||||
{
|
{
|
||||||
float spawnAt = Mathf.Max(0f, note.time - travelTime);
|
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
|
||||||
|
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
|
||||||
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
|
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
|
||||||
SpawnNote(note);
|
SpawnNote(note);
|
||||||
}
|
}
|
||||||
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
|
|||||||
float y = MapLayerY(note.lineLayer);
|
float y = MapLayerY(note.lineLayer);
|
||||||
|
|
||||||
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
|
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
|
||||||
float remaining = note.time - _audio.CurrentTime;
|
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
|
||||||
float travelTime = Mathf.Max(0.05f, remaining);
|
float travelTime = Mathf.Max(0.05f, remaining);
|
||||||
|
|
||||||
var info = new SpawnEventInfo
|
var info = new SpawnEventInfo
|
||||||
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
|
|||||||
position = new Vector3(x, y, 0f),
|
position = new Vector3(x, y, 0f),
|
||||||
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
|
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
|
||||||
hitDirection = MapCutDirection(note.cutDirection),
|
hitDirection = MapCutDirection(note.cutDirection),
|
||||||
useSpark = true,
|
useSpark = false,
|
||||||
speed = 2f,
|
speed = 2f,
|
||||||
travelTimeOverride = travelTime,
|
travelTimeOverride = travelTime,
|
||||||
};
|
};
|
||||||
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
|
|||||||
private static Direction MapCutDirection(int cut)
|
private static Direction MapCutDirection(int cut)
|
||||||
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
|
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
|
||||||
|
|
||||||
private IEnumerator WaitForCompletion(float clipLength)
|
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
|
||||||
{
|
{
|
||||||
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
|
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
|
||||||
|
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
|
||||||
|
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
|
||||||
|
yield return new WaitForSeconds(0.35f);
|
||||||
|
_scoreManager?.CompleteSong();
|
||||||
onLevelComplete?.Invoke();
|
onLevelComplete?.Invoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 bool isRightHand = true;
|
||||||
[SerializeField] private float maxDistance = 50f;
|
[SerializeField] private float maxDistance = 50f;
|
||||||
|
[SerializeField] private bool debugLogging = false;
|
||||||
|
[SerializeField] private float scrollSpeed = 2.4f;
|
||||||
|
[SerializeField] private float scrollDeadZone = 0.15f;
|
||||||
|
[SerializeField] private float dragScrollSpeed = 1.25f;
|
||||||
|
[SerializeField] private float dragClickThreshold = 0.025f;
|
||||||
|
|
||||||
private LineRenderer _line;
|
private LineRenderer _line;
|
||||||
private bool _prevTrigger;
|
private bool _prevTrigger;
|
||||||
private Selectable _currentHover;
|
private Selectable _currentHover;
|
||||||
|
private ScrollRect _dragScrollRect;
|
||||||
|
private Selectable _triggerPressSelectable;
|
||||||
|
private Vector2 _dragStartLocalPoint;
|
||||||
|
private float _dragStartNormalizedPosition;
|
||||||
|
private float _dragMaxNormalizedDelta;
|
||||||
|
|
||||||
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
|
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
|
||||||
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
|
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
|
||||||
@@ -40,6 +50,7 @@ namespace VRBeats
|
|||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
|
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
|
||||||
|
if (debugLogging)
|
||||||
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
|
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,12 +67,15 @@ namespace VRBeats
|
|||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
// 3초마다 연결된 디바이스 목록 출력
|
// 3초마다 연결된 디바이스 목록 출력
|
||||||
|
if (debugLogging)
|
||||||
|
{
|
||||||
_deviceLogTimer += Time.deltaTime;
|
_deviceLogTimer += Time.deltaTime;
|
||||||
if (_deviceLogTimer >= 3f)
|
if (_deviceLogTimer >= 3f)
|
||||||
{
|
{
|
||||||
_deviceLogTimer = 0f;
|
_deviceLogTimer = 0f;
|
||||||
LogConnectedDevices();
|
LogConnectedDevices();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool trigger = GetButton(CommonUsages.triggerButton);
|
bool trigger = GetButton(CommonUsages.triggerButton);
|
||||||
bool grip = GetButton(CommonUsages.gripButton);
|
bool grip = GetButton(CommonUsages.gripButton);
|
||||||
@@ -70,31 +84,41 @@ namespace VRBeats
|
|||||||
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
|
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
|
||||||
|
|
||||||
bool triggerDown = trigger && !_prevTrigger;
|
bool triggerDown = trigger && !_prevTrigger;
|
||||||
|
bool triggerUp = !trigger && _prevTrigger;
|
||||||
bool gripDown = grip && !_prevGrip;
|
bool gripDown = grip && !_prevGrip;
|
||||||
bool primaryDown = primary && !_prevPrimary;
|
bool primaryDown = primary && !_prevPrimary;
|
||||||
bool secondaryDown = secondary && !_prevSecondary;
|
bool secondaryDown = secondary && !_prevSecondary;
|
||||||
bool thumbstickDown = thumbstick && !_prevThumbstick;
|
bool thumbstickDown = thumbstick && !_prevThumbstick;
|
||||||
|
|
||||||
_prevTrigger = trigger;
|
|
||||||
_prevGrip = grip;
|
|
||||||
_prevPrimary = primary;
|
|
||||||
_prevSecondary = secondary;
|
|
||||||
_prevThumbstick = thumbstick;
|
|
||||||
|
|
||||||
string hand = isRightHand ? "R" : "L";
|
string hand = isRightHand ? "R" : "L";
|
||||||
|
if (debugLogging)
|
||||||
|
{
|
||||||
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
|
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
|
||||||
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
|
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
|
||||||
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
|
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
|
||||||
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
|
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
|
||||||
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
|
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
|
||||||
|
}
|
||||||
|
|
||||||
Ray ray = new Ray(transform.position, transform.forward);
|
Ray ray = new Ray(transform.position, transform.forward);
|
||||||
float hitDist = maxDistance;
|
float selectableHitDist = maxDistance;
|
||||||
|
float scrollHitDist = maxDistance;
|
||||||
|
|
||||||
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
|
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
|
||||||
|
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
|
||||||
|
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
|
||||||
|
|
||||||
|
bool beganScrollDrag = false;
|
||||||
|
if (triggerDown)
|
||||||
|
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
|
||||||
|
|
||||||
|
if (_dragScrollRect != null && trigger)
|
||||||
|
UpdateScrollDrag(ray);
|
||||||
|
else if (_dragScrollRect == null)
|
||||||
|
HandleScroll(scrollRect);
|
||||||
|
|
||||||
// 호버 변화 로그
|
// 호버 변화 로그
|
||||||
if (hit != _currentHover)
|
if (debugLogging && hit != _currentHover)
|
||||||
{
|
{
|
||||||
Debug.Log(hit != null
|
Debug.Log(hit != null
|
||||||
? $"[VRPointer] HOVER → {hit.gameObject.name}"
|
? $"[VRPointer] HOVER → {hit.gameObject.name}"
|
||||||
@@ -103,16 +127,21 @@ namespace VRBeats
|
|||||||
|
|
||||||
UpdateHoverState(hit);
|
UpdateHoverState(hit);
|
||||||
|
|
||||||
// 검지 트리거 또는 A/X 버튼으로 클릭
|
if (triggerUp && _dragScrollRect != null)
|
||||||
if (triggerDown || primaryDown)
|
EndScrollDrag(hand, ray);
|
||||||
|
|
||||||
|
// 검지 트리거 또는 A/X 버튼으로 클릭.
|
||||||
|
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
|
||||||
|
if ((triggerDown && !beganScrollDrag) || primaryDown)
|
||||||
{
|
{
|
||||||
if (_currentHover != null)
|
if (_currentHover != null)
|
||||||
{
|
{
|
||||||
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
|
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
|
||||||
|
if (debugLogging)
|
||||||
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
|
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
|
||||||
Click(_currentHover);
|
Click(_currentHover);
|
||||||
}
|
}
|
||||||
else
|
else if (debugLogging)
|
||||||
{
|
{
|
||||||
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
|
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
|
||||||
$"pos={transform.position:F2} fwd={transform.forward:F2}");
|
$"pos={transform.position:F2} fwd={transform.forward:F2}");
|
||||||
@@ -121,6 +150,12 @@ namespace VRBeats
|
|||||||
}
|
}
|
||||||
|
|
||||||
DrawLine(hitDist);
|
DrawLine(hitDist);
|
||||||
|
|
||||||
|
_prevTrigger = trigger;
|
||||||
|
_prevGrip = grip;
|
||||||
|
_prevPrimary = primary;
|
||||||
|
_prevSecondary = secondary;
|
||||||
|
_prevThumbstick = thumbstick;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LogConnectedDevices()
|
private void LogConnectedDevices()
|
||||||
@@ -222,6 +257,7 @@ namespace VRBeats
|
|||||||
foreach (Selectable sel in Selectable.allSelectablesArray)
|
foreach (Selectable sel in Selectable.allSelectablesArray)
|
||||||
{
|
{
|
||||||
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
|
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
|
||||||
|
if (!IsOnEnabledCanvas(sel)) continue;
|
||||||
|
|
||||||
var rt = sel.GetComponent<RectTransform>();
|
var rt = sel.GetComponent<RectTransform>();
|
||||||
if (rt == null) continue;
|
if (rt == null) continue;
|
||||||
@@ -260,6 +296,189 @@ namespace VRBeats
|
|||||||
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
|
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
|
||||||
|
{
|
||||||
|
ScrollRect closest = null;
|
||||||
|
float closestDist = maxDist;
|
||||||
|
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
|
||||||
|
|
||||||
|
foreach (ScrollRect scroll in all)
|
||||||
|
{
|
||||||
|
if (!scroll.isActiveAndEnabled) continue;
|
||||||
|
if (!IsOnEnabledCanvas(scroll)) continue;
|
||||||
|
|
||||||
|
RectTransform rt = scroll.viewport != null
|
||||||
|
? scroll.viewport
|
||||||
|
: scroll.GetComponent<RectTransform>();
|
||||||
|
if (rt == null) continue;
|
||||||
|
|
||||||
|
Vector3[] corners = new Vector3[4];
|
||||||
|
rt.GetWorldCorners(corners);
|
||||||
|
|
||||||
|
Vector3 normal = rt.forward;
|
||||||
|
if (Vector3.Dot(normal, ray.direction) >= 0f)
|
||||||
|
normal = -normal;
|
||||||
|
|
||||||
|
Plane plane = new Plane(normal, corners[0]);
|
||||||
|
if (!plane.Raycast(ray, out float dist)) continue;
|
||||||
|
if (dist >= closestDist || dist <= 0f) continue;
|
||||||
|
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
|
||||||
|
|
||||||
|
closestDist = dist;
|
||||||
|
closest = scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closest != null)
|
||||||
|
maxDist = closestDist;
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOnEnabledCanvas(Component component)
|
||||||
|
{
|
||||||
|
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
|
||||||
|
if (canvases.Length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (int i = 0; i < canvases.Length; i++)
|
||||||
|
{
|
||||||
|
Canvas canvas = canvases[i];
|
||||||
|
if (canvas == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleScroll(ScrollRect scrollRect)
|
||||||
|
{
|
||||||
|
if (!CanScrollVertically(scrollRect))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
|
||||||
|
if (Mathf.Abs(axis.y) < scrollDeadZone)
|
||||||
|
return;
|
||||||
|
|
||||||
|
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
|
||||||
|
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
|
||||||
|
{
|
||||||
|
if (!CanScrollVertically(scrollRect))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_dragScrollRect = scrollRect;
|
||||||
|
_triggerPressSelectable = pressSelectable;
|
||||||
|
_dragStartLocalPoint = localPoint;
|
||||||
|
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
|
||||||
|
_dragMaxNormalizedDelta = 0f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateScrollDrag(Ray ray)
|
||||||
|
{
|
||||||
|
if (_dragScrollRect == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
|
||||||
|
return;
|
||||||
|
|
||||||
|
float deltaY = localPoint.y - _dragStartLocalPoint.y;
|
||||||
|
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
|
||||||
|
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
|
||||||
|
|
||||||
|
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
|
||||||
|
_dragStartNormalizedPosition - normalizedDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndScrollDrag(string hand, Ray ray)
|
||||||
|
{
|
||||||
|
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
|
||||||
|
ScrollRect scrollRect = _dragScrollRect;
|
||||||
|
Selectable pressSelectable = _triggerPressSelectable;
|
||||||
|
float startNormalizedPosition = _dragStartNormalizedPosition;
|
||||||
|
|
||||||
|
ClearScrollDrag();
|
||||||
|
|
||||||
|
if (!shouldClick)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (scrollRect != null)
|
||||||
|
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
|
||||||
|
|
||||||
|
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
|
||||||
|
{
|
||||||
|
if (debugLogging)
|
||||||
|
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
|
||||||
|
Click(pressSelectable);
|
||||||
|
}
|
||||||
|
else if (debugLogging)
|
||||||
|
{
|
||||||
|
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
|
||||||
|
$"pos={transform.position:F2} fwd={transform.forward:F2}");
|
||||||
|
DebugRaycastAttempt(ray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearScrollDrag()
|
||||||
|
{
|
||||||
|
_dragScrollRect = null;
|
||||||
|
_triggerPressSelectable = null;
|
||||||
|
_dragStartLocalPoint = Vector2.zero;
|
||||||
|
_dragStartNormalizedPosition = 0f;
|
||||||
|
_dragMaxNormalizedDelta = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanScrollVertically(ScrollRect scrollRect)
|
||||||
|
{
|
||||||
|
if (scrollRect == null || !scrollRect.vertical)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
RectTransform viewport = scrollRect.viewport != null
|
||||||
|
? scrollRect.viewport
|
||||||
|
: scrollRect.GetComponent<RectTransform>();
|
||||||
|
|
||||||
|
if (viewport == null || scrollRect.content == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return scrollRect.content.rect.height > viewport.rect.height + 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
|
||||||
|
{
|
||||||
|
localPoint = Vector2.zero;
|
||||||
|
viewportHeight = 1f;
|
||||||
|
|
||||||
|
RectTransform rt = scrollRect.viewport != null
|
||||||
|
? scrollRect.viewport
|
||||||
|
: scrollRect.GetComponent<RectTransform>();
|
||||||
|
if (rt == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Vector3[] corners = new Vector3[4];
|
||||||
|
rt.GetWorldCorners(corners);
|
||||||
|
|
||||||
|
Vector3 normal = rt.forward;
|
||||||
|
if (Vector3.Dot(normal, ray.direction) >= 0f)
|
||||||
|
normal = -normal;
|
||||||
|
|
||||||
|
Plane plane = new Plane(normal, corners[0]);
|
||||||
|
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
|
||||||
|
localPoint = new Vector2(local.x, local.y);
|
||||||
|
viewportHeight = Mathf.Max(1f, rt.rect.height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private bool GetButton(InputFeatureUsage<bool> usage)
|
private bool GetButton(InputFeatureUsage<bool> usage)
|
||||||
{
|
{
|
||||||
var chars = InputDeviceCharacteristics.Controller |
|
var chars = InputDeviceCharacteristics.Controller |
|
||||||
@@ -274,5 +493,20 @@ namespace VRBeats
|
|||||||
devices[0].TryGetFeatureValue(usage, out bool pressed);
|
devices[0].TryGetFeatureValue(usage, out bool pressed);
|
||||||
return pressed;
|
return pressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
|
||||||
|
{
|
||||||
|
var chars = InputDeviceCharacteristics.Controller |
|
||||||
|
(isRightHand
|
||||||
|
? InputDeviceCharacteristics.Right
|
||||||
|
: InputDeviceCharacteristics.Left);
|
||||||
|
|
||||||
|
var devices = new List<InputDevice>();
|
||||||
|
InputDevices.GetDevicesWithCharacteristics(chars, devices);
|
||||||
|
if (devices.Count == 0) return Vector2.zero;
|
||||||
|
|
||||||
|
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
|
||||||
|
return axis;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ namespace VRBeats
|
|||||||
if (!isRight && !isLeft) continue;
|
if (!isRight && !isLeft) continue;
|
||||||
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
|
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
|
||||||
if (go.GetComponent<LineRenderer>() == null) continue;
|
if (go.GetComponent<LineRenderer>() == null) continue;
|
||||||
|
|
||||||
|
DisableToolkitPointerComponents(go);
|
||||||
|
|
||||||
if (go.GetComponent<VRPointerController>() != null) continue;
|
if (go.GetComponent<VRPointerController>() != null) continue;
|
||||||
|
|
||||||
var pointer = go.AddComponent<VRPointerController>();
|
var pointer = go.AddComponent<VRPointerController>();
|
||||||
@@ -94,8 +97,18 @@ namespace VRBeats
|
|||||||
if (disabledByDefault)
|
if (disabledByDefault)
|
||||||
pointer.enabled = false;
|
pointer.enabled = false;
|
||||||
|
|
||||||
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void DisableToolkitPointerComponents(GameObject go)
|
||||||
|
{
|
||||||
|
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
|
||||||
|
if (rayInteractor != null)
|
||||||
|
rayInteractor.enabled = false;
|
||||||
|
|
||||||
|
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
|
||||||
|
if (lineVisual != null)
|
||||||
|
lineVisual.enabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,28 +44,6 @@ MonoBehaviour:
|
|||||||
balance:
|
balance:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 0
|
m_Value: 0
|
||||||
--- !u!114 &-8104416584915340131
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 3
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 0}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 0}
|
|
||||||
m_Name: CopyPasteTestComponent2
|
|
||||||
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent2
|
|
||||||
active: 1
|
|
||||||
p1:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
p2:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
p21:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
--- !u!114 &-7750755424749557576
|
--- !u!114 &-7750755424749557576
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 3
|
m_ObjectHideFlags: 3
|
||||||
@@ -239,19 +217,6 @@ MonoBehaviour:
|
|||||||
maxNits:
|
maxNits:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 1000
|
m_Value: 1000
|
||||||
--- !u!114 &-5360449096862653589
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 3
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 0}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 0}
|
|
||||||
m_Name: VolumeComponentSupportedEverywhere
|
|
||||||
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedEverywhere
|
|
||||||
active: 1
|
|
||||||
--- !u!114 &-5139089513906902183
|
--- !u!114 &-5139089513906902183
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 3
|
m_ObjectHideFlags: 3
|
||||||
@@ -408,28 +373,6 @@ MonoBehaviour:
|
|||||||
tint:
|
tint:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 0
|
m_Value: 0
|
||||||
--- !u!114 &-581120513425526550
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 3
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 0}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 0}
|
|
||||||
m_Name: CopyPasteTestComponent3
|
|
||||||
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent3
|
|
||||||
active: 1
|
|
||||||
p1:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
p2:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
p31:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: {r: 0, g: 0, b: 0, a: 1}
|
|
||||||
--- !u!114 &11400000
|
--- !u!114 &11400000
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -852,19 +795,6 @@ MonoBehaviour:
|
|||||||
intensity:
|
intensity:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 0
|
m_Value: 0
|
||||||
--- !u!114 &6940869943325143175
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 3
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 0}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 0}
|
|
||||||
m_Name: VolumeComponentSupportedOnAnySRP
|
|
||||||
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedOnAnySRP
|
|
||||||
active: 1
|
|
||||||
--- !u!114 &7173750748008157695
|
--- !u!114 &7173750748008157695
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 3
|
m_ObjectHideFlags: 3
|
||||||
@@ -961,22 +891,3 @@ MonoBehaviour:
|
|||||||
blueOutBlueIn:
|
blueOutBlueIn:
|
||||||
m_OverrideState: 1
|
m_OverrideState: 1
|
||||||
m_Value: 100
|
m_Value: 100
|
||||||
--- !u!114 &9122958982931076880
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 3
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 0}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 0}
|
|
||||||
m_Name: CopyPasteTestComponent1
|
|
||||||
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent1
|
|
||||||
active: 1
|
|
||||||
p1:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
p2:
|
|
||||||
m_OverrideState: 1
|
|
||||||
m_Value: 0
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ MonoBehaviour:
|
|||||||
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
|
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
|
||||||
m_autoSizeTextContainer: 0
|
m_autoSizeTextContainer: 0
|
||||||
m_IsTextObjectScaleStatic: 0
|
m_IsTextObjectScaleStatic: 0
|
||||||
m_fallbackFontAssets: []
|
m_fallbackFontAssets:
|
||||||
|
- {fileID: 11400000, guid: f6c6fe0f3c5912a43a8a6707e336d2ea, type: 2}
|
||||||
m_matchMaterialPreset: 1
|
m_matchMaterialPreset: 1
|
||||||
m_HideSubTextObjects: 1
|
m_HideSubTextObjects: 1
|
||||||
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
|
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!21 &2100000
|
||||||
|
Material:
|
||||||
|
serializedVersion: 8
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_Name: SkyBox
|
||||||
|
m_Shader: {fileID: 108, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_Parent: {fileID: 0}
|
||||||
|
m_ModifiedSerializedProperties: 0
|
||||||
|
m_ValidKeywords: []
|
||||||
|
m_InvalidKeywords:
|
||||||
|
- _MAPPING_LATITUDE_LONGITUDE_LAYOUT
|
||||||
|
m_LightmapFlags: 4
|
||||||
|
m_EnableInstancingVariants: 0
|
||||||
|
m_DoubleSidedGI: 0
|
||||||
|
m_CustomRenderQueue: -1
|
||||||
|
stringTagMap: {}
|
||||||
|
disabledShaderPasses:
|
||||||
|
- MOTIONVECTORS
|
||||||
|
m_LockedProperties:
|
||||||
|
m_SavedProperties:
|
||||||
|
serializedVersion: 3
|
||||||
|
m_TexEnvs:
|
||||||
|
- _BackTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _BaseMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _BumpMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _DetailAlbedoMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _DetailMask:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _DetailNormalMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _DownTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _EmissionMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _FrontTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _LeftTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _MainTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _MetallicGlossMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _OcclusionMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _ParallaxMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _RightTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _SpecGlossMap:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- _UpTex:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- unity_Lightmaps:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- unity_LightmapsInd:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
- unity_ShadowMasks:
|
||||||
|
m_Texture: {fileID: 0}
|
||||||
|
m_Scale: {x: 1, y: 1}
|
||||||
|
m_Offset: {x: 0, y: 0}
|
||||||
|
m_Ints: []
|
||||||
|
m_Floats:
|
||||||
|
- _AddPrecomputedVelocity: 0
|
||||||
|
- _AlphaClip: 0
|
||||||
|
- _AlphaToMask: 0
|
||||||
|
- _Blend: 0
|
||||||
|
- _BlendModePreserveSpecular: 1
|
||||||
|
- _BumpScale: 1
|
||||||
|
- _ClearCoatMask: 0
|
||||||
|
- _ClearCoatSmoothness: 0
|
||||||
|
- _Cull: 2
|
||||||
|
- _Cutoff: 0.5
|
||||||
|
- _DetailAlbedoMapScale: 1
|
||||||
|
- _DetailNormalMapScale: 1
|
||||||
|
- _DstBlend: 0
|
||||||
|
- _DstBlendAlpha: 0
|
||||||
|
- _EnvironmentReflections: 1
|
||||||
|
- _Exposure: 1
|
||||||
|
- _GlossMapScale: 0
|
||||||
|
- _Glossiness: 0
|
||||||
|
- _GlossyReflections: 0
|
||||||
|
- _ImageType: 0
|
||||||
|
- _Layout: 0
|
||||||
|
- _Mapping: 1
|
||||||
|
- _Metallic: 0
|
||||||
|
- _MirrorOnBack: 0
|
||||||
|
- _OcclusionStrength: 1
|
||||||
|
- _Parallax: 0.005
|
||||||
|
- _QueueOffset: 0
|
||||||
|
- _ReceiveShadows: 1
|
||||||
|
- _Rotation: 0
|
||||||
|
- _Smoothness: 0.5
|
||||||
|
- _SmoothnessTextureChannel: 0
|
||||||
|
- _SpecularHighlights: 1
|
||||||
|
- _SrcBlend: 1
|
||||||
|
- _SrcBlendAlpha: 1
|
||||||
|
- _Surface: 0
|
||||||
|
- _WorkflowMode: 1
|
||||||
|
- _XRMotionVectorsPass: 1
|
||||||
|
- _ZWrite: 1
|
||||||
|
m_Colors:
|
||||||
|
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||||
|
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||||
|
- _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1}
|
||||||
|
- _Tint: {r: 0.5, g: 0.5, b: 0.5, a: 0.5}
|
||||||
|
m_BuildTextureStacks: []
|
||||||
|
m_AllowLocking: 1
|
||||||
|
--- !u!114 &5645475041611047199
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 11
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
|
||||||
|
version: 10
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fecd661b14876064fa838cbb52ca425e
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 2100000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -76,7 +76,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 748387694296687208}
|
m_GameObject: {fileID: 748387694296687208}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
|
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
@@ -170,7 +170,7 @@ MonoBehaviour:
|
|||||||
m_AllowHoveredActivate: 0
|
m_AllowHoveredActivate: 0
|
||||||
m_TargetPriorityMode: 0
|
m_TargetPriorityMode: 0
|
||||||
m_HideControllerOnSelect: 0
|
m_HideControllerOnSelect: 0
|
||||||
m_InputCompatibilityMode: 0
|
m_InputCompatibilityMode: 2
|
||||||
m_PlayAudioClipOnSelectEntered: 0
|
m_PlayAudioClipOnSelectEntered: 0
|
||||||
m_AudioClipForOnSelectEntered: {fileID: 0}
|
m_AudioClipForOnSelectEntered: {fileID: 0}
|
||||||
m_PlayAudioClipOnSelectExited: 0
|
m_PlayAudioClipOnSelectExited: 0
|
||||||
@@ -528,7 +528,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 748387694296687208}
|
m_GameObject: {fileID: 748387694296687208}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
@@ -760,7 +760,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 748387694868252494}
|
m_GameObject: {fileID: 748387694868252494}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
|
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
@@ -854,7 +854,7 @@ MonoBehaviour:
|
|||||||
m_AllowHoveredActivate: 0
|
m_AllowHoveredActivate: 0
|
||||||
m_TargetPriorityMode: 0
|
m_TargetPriorityMode: 0
|
||||||
m_HideControllerOnSelect: 0
|
m_HideControllerOnSelect: 0
|
||||||
m_InputCompatibilityMode: 0
|
m_InputCompatibilityMode: 2
|
||||||
m_PlayAudioClipOnSelectEntered: 0
|
m_PlayAudioClipOnSelectEntered: 0
|
||||||
m_AudioClipForOnSelectEntered: {fileID: 0}
|
m_AudioClipForOnSelectEntered: {fileID: 0}
|
||||||
m_PlayAudioClipOnSelectExited: 0
|
m_PlayAudioClipOnSelectExited: 0
|
||||||
@@ -1212,7 +1212,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 748387694868252494}
|
m_GameObject: {fileID: 748387694868252494}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 8546668446538128435}
|
m_GameObject: {fileID: 8546668446538128435}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
@@ -1003,7 +1003,7 @@ MonoBehaviour:
|
|||||||
m_PrefabInstance: {fileID: 0}
|
m_PrefabInstance: {fileID: 0}
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 8546668447105775893}
|
m_GameObject: {fileID: 8546668447105775893}
|
||||||
m_Enabled: 1
|
m_Enabled: 0
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
@@ -1295,7 +1295,7 @@ Camera:
|
|||||||
m_GameObject: {fileID: 8546668447772986810}
|
m_GameObject: {fileID: 8546668447772986810}
|
||||||
m_Enabled: 1
|
m_Enabled: 1
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_ClearFlags: 2
|
m_ClearFlags: 1
|
||||||
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
|
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
|
||||||
m_projectionMatrixMode: 1
|
m_projectionMatrixMode: 1
|
||||||
m_GateFitMode: 2
|
m_GateFitMode: 2
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ MonoBehaviour:
|
|||||||
handSettings:
|
handSettings:
|
||||||
interactPoint: {fileID: 3074267110786978836}
|
interactPoint: {fileID: 3074267110786978836}
|
||||||
highlightPoint: {fileID: 3074267110786978836}
|
highlightPoint: {fileID: 3074267110786978836}
|
||||||
rotationOffset: {x: 0, y: 90, z: 25}
|
rotationOffset: {x: 25, y: 0, z: 0}
|
||||||
canInteract: 1
|
canInteract: 1
|
||||||
rightHandAnimationSettings:
|
rightHandAnimationSettings:
|
||||||
animation: {fileID: 0}
|
animation: {fileID: 0}
|
||||||
@@ -522,7 +522,7 @@ MonoBehaviour:
|
|||||||
m_GameObject: {fileID: 5575416034875238503}
|
m_GameObject: {fileID: 5575416034875238503}
|
||||||
m_Enabled: 1
|
m_Enabled: 1
|
||||||
m_EditorHideFlags: 0
|
m_EditorHideFlags: 0
|
||||||
m_Script: {fileID: 11500000, guid: c68346ff56573a1429560a527ad447e0, type: 3}
|
m_Script: {fileID: 11500000, guid: 4de824eda67bd1c4ba4d379a9debd2b3, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
fastCollisionListener: {fileID: 5407220909436794986}
|
fastCollisionListener: {fileID: 5407220909436794986}
|
||||||
@@ -533,6 +533,7 @@ MonoBehaviour:
|
|||||||
hitForce: 0
|
hitForce: 0
|
||||||
maxHitForce: 0
|
maxHitForce: 0
|
||||||
canDismember: 0
|
canDismember: 0
|
||||||
|
colorSide: 1
|
||||||
--- !u!114 &5407220909436794986
|
--- !u!114 &5407220909436794986
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -2639,7 +2639,7 @@ RectTransform:
|
|||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 661667650}
|
m_GameObject: {fileID: 661667650}
|
||||||
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
|
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
|
||||||
m_LocalPosition: {x: 0, y: 0, z: 19.76}
|
m_LocalPosition: {x: 0, y: 0, z: 20.29}
|
||||||
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
|
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children:
|
m_Children:
|
||||||
@@ -2652,7 +2652,7 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
|
||||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||||
m_AnchoredPosition: {x: 24.54, y: 4.415}
|
m_AnchoredPosition: {x: 24.01, y: 4.71}
|
||||||
m_SizeDelta: {x: 105.885, y: 71.226}
|
m_SizeDelta: {x: 105.885, y: 71.226}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!114 &661667652
|
--- !u!114 &661667652
|
||||||
@@ -7157,7 +7157,7 @@ RectTransform:
|
|||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 1946485404}
|
m_GameObject: {fileID: 1946485404}
|
||||||
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
|
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
|
||||||
m_LocalPosition: {x: 0, y: 0, z: 17.9}
|
m_LocalPosition: {x: 0, y: 0, z: 18.33}
|
||||||
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
|
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children:
|
m_Children:
|
||||||
@@ -7167,7 +7167,7 @@ RectTransform:
|
|||||||
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
|
||||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||||
m_AnchoredPosition: {x: -22.2, y: 4.39}
|
m_AnchoredPosition: {x: -21.77, y: 4.39}
|
||||||
m_SizeDelta: {x: 105.89, y: 66.53}
|
m_SizeDelta: {x: 105.89, y: 66.53}
|
||||||
m_Pivot: {x: 0.5, y: 0.5}
|
m_Pivot: {x: 0.5, y: 0.5}
|
||||||
--- !u!114 &1946485406
|
--- !u!114 &1946485406
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ namespace VRBeats
|
|||||||
|
|
||||||
if (Cut(info.hitPoint, cutDir, insideMaterial))
|
if (Cut(info.hitPoint, cutDir, insideMaterial))
|
||||||
{
|
{
|
||||||
|
Color trailColor = beatCube != null
|
||||||
|
? VR_BeatManager.instance.GetColorFromColorSide(beatCube.ThisColorSide)
|
||||||
|
: Color.white;
|
||||||
|
Vector3 saberUp = beatDamageInfo.hitObject != null ? beatDamageInfo.hitObject.transform.up : cutDir;
|
||||||
|
SliceTrailEffect.Spawn(info.hitPoint, info.hitDir, saberUp, trailColor);
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
|
||||||
|
namespace VRBeats
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a short world-space ribbon from the whole saber blade instead of
|
||||||
|
/// attaching a TrailRenderer to the tip. This keeps the afterimage on the
|
||||||
|
/// blade surface and avoids the "propeller" look from a single end point.
|
||||||
|
/// </summary>
|
||||||
|
public class SaberTrailEffect : MonoBehaviour
|
||||||
|
{
|
||||||
|
private const int MaxSamples = 18;
|
||||||
|
private const float TrailLifetime = 0.13f;
|
||||||
|
private const float MinSwingSpeed = 0.75f;
|
||||||
|
private const float MinSampleDistance = 0.012f;
|
||||||
|
private const float BladeBaseT = 0.10f;
|
||||||
|
private const float BladeTipT = 0.98f;
|
||||||
|
|
||||||
|
private readonly List<BladeSample> samples = new List<BladeSample>(MaxSamples);
|
||||||
|
private readonly List<Vector3> vertices = new List<Vector3>(MaxSamples * 2);
|
||||||
|
private readonly List<Color> colors = new List<Color>(MaxSamples * 2);
|
||||||
|
private readonly List<Vector2> uvs = new List<Vector2>(MaxSamples * 2);
|
||||||
|
private readonly List<int> triangles = new List<int>((MaxSamples - 1) * 12);
|
||||||
|
|
||||||
|
private Transform bladeStart;
|
||||||
|
private Transform bladeEnd;
|
||||||
|
private GameObject wideObject;
|
||||||
|
private GameObject coreObject;
|
||||||
|
private Mesh wideMesh;
|
||||||
|
private Mesh coreMesh;
|
||||||
|
private MeshRenderer wideRenderer;
|
||||||
|
private MeshRenderer coreRenderer;
|
||||||
|
private Material wideMaterial;
|
||||||
|
private Material coreMaterial;
|
||||||
|
|
||||||
|
private Color trailColor = Color.cyan;
|
||||||
|
private bool visible = true;
|
||||||
|
private bool hasLastFrame;
|
||||||
|
private Vector3 lastBase;
|
||||||
|
private Vector3 lastTip;
|
||||||
|
|
||||||
|
private struct BladeSample
|
||||||
|
{
|
||||||
|
public Vector3 basePos;
|
||||||
|
public Vector3 tipPos;
|
||||||
|
public float time;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
RemoveLegacyTrailRenderers();
|
||||||
|
ResolveBladeAnchors();
|
||||||
|
EnsureRenderers();
|
||||||
|
SetColor(trailColor);
|
||||||
|
SetVisible(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LateUpdate()
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
hasLastFrame = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bladeEnd == null)
|
||||||
|
{
|
||||||
|
ResolveBladeAnchors();
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 basePos;
|
||||||
|
Vector3 tipPos;
|
||||||
|
GetBladeSegment(out basePos, out tipPos);
|
||||||
|
|
||||||
|
float now = Time.time;
|
||||||
|
float speed = 0f;
|
||||||
|
float moved = 0f;
|
||||||
|
|
||||||
|
if (hasLastFrame)
|
||||||
|
{
|
||||||
|
float deltaTime = Mathf.Max(Time.deltaTime, 0.0001f);
|
||||||
|
speed = Mathf.Max(
|
||||||
|
Vector3.Distance(basePos, lastBase),
|
||||||
|
Vector3.Distance(tipPos, lastTip)) / deltaTime;
|
||||||
|
moved = Mathf.Max(
|
||||||
|
Vector3.Distance(basePos, lastBase),
|
||||||
|
Vector3.Distance(tipPos, lastTip));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLastFrame || (speed >= MinSwingSpeed && moved >= MinSampleDistance))
|
||||||
|
{
|
||||||
|
AddSample(basePos, tipPos, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrimExpiredSamples(now);
|
||||||
|
BuildRibbon(wideMesh, now, 0f, 1f, trailColor, 0.10f, 0.36f);
|
||||||
|
|
||||||
|
Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f);
|
||||||
|
BuildRibbon(coreMesh, now, 0.42f, 1f, coreColor, 0.14f, 0.48f);
|
||||||
|
|
||||||
|
lastBase = basePos;
|
||||||
|
lastTip = tipPos;
|
||||||
|
hasLastFrame = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
hasLastFrame = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
DestroyRuntimeObject(wideObject);
|
||||||
|
DestroyRuntimeObject(coreObject);
|
||||||
|
DestroyRuntimeObject(wideMesh);
|
||||||
|
DestroyRuntimeObject(coreMesh);
|
||||||
|
DestroyRuntimeObject(wideMaterial);
|
||||||
|
DestroyRuntimeObject(coreMaterial);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetVisible(bool value)
|
||||||
|
{
|
||||||
|
visible = value;
|
||||||
|
|
||||||
|
if (wideRenderer != null)
|
||||||
|
{
|
||||||
|
wideRenderer.enabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coreRenderer != null)
|
||||||
|
{
|
||||||
|
coreRenderer.enabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
hasLastFrame = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetColor(Color color)
|
||||||
|
{
|
||||||
|
trailColor = NormalizeColor(color);
|
||||||
|
EnsureRenderers();
|
||||||
|
ApplyMaterialColor(wideMaterial, trailColor, 0.34f);
|
||||||
|
|
||||||
|
Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f);
|
||||||
|
ApplyMaterialColor(coreMaterial, coreColor, 0.50f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveBladeAnchors()
|
||||||
|
{
|
||||||
|
bladeStart = FindChildRecursive(transform, "Start");
|
||||||
|
bladeEnd = FindChildRecursive(transform, "End");
|
||||||
|
|
||||||
|
if (bladeEnd == null)
|
||||||
|
{
|
||||||
|
bladeEnd = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bladeStart == null)
|
||||||
|
{
|
||||||
|
bladeStart = transform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GetBladeSegment(out Vector3 basePos, out Vector3 tipPos)
|
||||||
|
{
|
||||||
|
Vector3 start = bladeStart != null ? bladeStart.position : transform.position;
|
||||||
|
Vector3 end = bladeEnd != null ? bladeEnd.position : transform.position + transform.forward;
|
||||||
|
|
||||||
|
if (Vector3.Distance(start, end) < 0.001f)
|
||||||
|
{
|
||||||
|
end = start + transform.forward * 0.8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
basePos = Vector3.Lerp(start, end, BladeBaseT);
|
||||||
|
tipPos = Vector3.Lerp(start, end, BladeTipT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSample(Vector3 basePos, Vector3 tipPos, float time)
|
||||||
|
{
|
||||||
|
if (samples.Count >= MaxSamples)
|
||||||
|
{
|
||||||
|
samples.RemoveAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
samples.Add(new BladeSample
|
||||||
|
{
|
||||||
|
basePos = basePos,
|
||||||
|
tipPos = tipPos,
|
||||||
|
time = time
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrimExpiredSamples(float now)
|
||||||
|
{
|
||||||
|
for (int i = samples.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (now - samples[i].time > TrailLifetime)
|
||||||
|
{
|
||||||
|
samples.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRibbon(Mesh mesh, float now, float innerT, float outerT, Color color, float innerAlpha, float outerAlpha)
|
||||||
|
{
|
||||||
|
if (mesh == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vertices.Clear();
|
||||||
|
colors.Clear();
|
||||||
|
uvs.Clear();
|
||||||
|
triangles.Clear();
|
||||||
|
|
||||||
|
if (samples.Count < 2)
|
||||||
|
{
|
||||||
|
mesh.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < samples.Count; i++)
|
||||||
|
{
|
||||||
|
BladeSample sample = samples[i];
|
||||||
|
float age01 = Mathf.Clamp01((now - sample.time) / TrailLifetime);
|
||||||
|
float fade = Mathf.Pow(1f - age01, 1.7f);
|
||||||
|
float along = samples.Count <= 1 ? 0f : i / (float)(samples.Count - 1);
|
||||||
|
|
||||||
|
Vector3 inner = Vector3.Lerp(sample.basePos, sample.tipPos, innerT);
|
||||||
|
Vector3 outer = Vector3.Lerp(sample.basePos, sample.tipPos, outerT);
|
||||||
|
|
||||||
|
vertices.Add(inner);
|
||||||
|
vertices.Add(outer);
|
||||||
|
colors.Add(WithAlpha(color, fade * innerAlpha));
|
||||||
|
colors.Add(WithAlpha(color, fade * outerAlpha));
|
||||||
|
uvs.Add(new Vector2(0f, along));
|
||||||
|
uvs.Add(new Vector2(1f, along));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < samples.Count - 1; i++)
|
||||||
|
{
|
||||||
|
int a = i * 2;
|
||||||
|
int b = a + 1;
|
||||||
|
int c = a + 2;
|
||||||
|
int d = a + 3;
|
||||||
|
|
||||||
|
triangles.Add(a);
|
||||||
|
triangles.Add(b);
|
||||||
|
triangles.Add(c);
|
||||||
|
triangles.Add(b);
|
||||||
|
triangles.Add(d);
|
||||||
|
triangles.Add(c);
|
||||||
|
|
||||||
|
triangles.Add(c);
|
||||||
|
triangles.Add(b);
|
||||||
|
triangles.Add(a);
|
||||||
|
triangles.Add(c);
|
||||||
|
triangles.Add(d);
|
||||||
|
triangles.Add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.Clear();
|
||||||
|
mesh.SetVertices(vertices);
|
||||||
|
mesh.SetColors(colors);
|
||||||
|
mesh.SetUVs(0, uvs);
|
||||||
|
mesh.SetTriangles(triangles, 0);
|
||||||
|
mesh.RecalculateBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureRenderers()
|
||||||
|
{
|
||||||
|
if (wideObject == null)
|
||||||
|
{
|
||||||
|
wideRenderer = CreateRibbonObject("SaberBladeAfterimage_Wide", out wideObject, out wideMesh, out wideMaterial, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coreObject == null)
|
||||||
|
{
|
||||||
|
coreRenderer = CreateRibbonObject("SaberBladeAfterimage_Core", out coreObject, out coreMesh, out coreMaterial, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeshRenderer CreateRibbonObject(string objectName, out GameObject ribbonObject, out Mesh mesh, out Material material, bool additive)
|
||||||
|
{
|
||||||
|
ribbonObject = new GameObject(objectName);
|
||||||
|
ribbonObject.hideFlags = HideFlags.DontSave;
|
||||||
|
ribbonObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
|
||||||
|
ribbonObject.transform.localScale = Vector3.one;
|
||||||
|
|
||||||
|
mesh = new Mesh
|
||||||
|
{
|
||||||
|
name = objectName + " Mesh"
|
||||||
|
};
|
||||||
|
mesh.MarkDynamic();
|
||||||
|
|
||||||
|
MeshFilter filter = ribbonObject.AddComponent<MeshFilter>();
|
||||||
|
filter.sharedMesh = mesh;
|
||||||
|
|
||||||
|
MeshRenderer renderer = ribbonObject.AddComponent<MeshRenderer>();
|
||||||
|
material = CreateTrailMaterial(objectName + " Material", additive);
|
||||||
|
renderer.sharedMaterial = material;
|
||||||
|
renderer.shadowCastingMode = ShadowCastingMode.Off;
|
||||||
|
renderer.receiveShadows = false;
|
||||||
|
renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
|
||||||
|
renderer.enabled = visible;
|
||||||
|
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Material CreateTrailMaterial(string materialName, bool additive)
|
||||||
|
{
|
||||||
|
Shader shader = Shader.Find("Sprites/Default");
|
||||||
|
if (shader == null)
|
||||||
|
{
|
||||||
|
shader = Shader.Find("Unlit/Transparent");
|
||||||
|
}
|
||||||
|
|
||||||
|
Material material = new Material(shader)
|
||||||
|
{
|
||||||
|
name = materialName,
|
||||||
|
hideFlags = HideFlags.DontSave,
|
||||||
|
renderQueue = (int)RenderQueue.Transparent
|
||||||
|
};
|
||||||
|
|
||||||
|
if (material.HasProperty("_SrcBlend"))
|
||||||
|
{
|
||||||
|
material.SetInt("_SrcBlend", additive ? (int)BlendMode.SrcAlpha : (int)BlendMode.SrcAlpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material.HasProperty("_DstBlend"))
|
||||||
|
{
|
||||||
|
material.SetInt("_DstBlend", additive ? (int)BlendMode.One : (int)BlendMode.OneMinusSrcAlpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material.HasProperty("_ZWrite"))
|
||||||
|
{
|
||||||
|
material.SetInt("_ZWrite", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyMaterialColor(Material material, Color color, float alpha)
|
||||||
|
{
|
||||||
|
if (material == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color materialColor = WithAlpha(color, alpha);
|
||||||
|
if (material.HasProperty("_Color"))
|
||||||
|
{
|
||||||
|
material.SetColor("_Color", materialColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Clear()
|
||||||
|
{
|
||||||
|
samples.Clear();
|
||||||
|
|
||||||
|
if (wideMesh != null)
|
||||||
|
{
|
||||||
|
wideMesh.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coreMesh != null)
|
||||||
|
{
|
||||||
|
coreMesh.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveLegacyTrailRenderers()
|
||||||
|
{
|
||||||
|
TrailRenderer[] legacyTrails = GetComponentsInChildren<TrailRenderer>(true);
|
||||||
|
for (int i = legacyTrails.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
DestroyRuntimeObject(legacyTrails[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transform FindChildRecursive(Transform root, string childName)
|
||||||
|
{
|
||||||
|
if (root == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.name == childName)
|
||||||
|
{
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < root.childCount; i++)
|
||||||
|
{
|
||||||
|
Transform found = FindChildRecursive(root.GetChild(i), childName);
|
||||||
|
if (found != null)
|
||||||
|
{
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color NormalizeColor(Color color)
|
||||||
|
{
|
||||||
|
float max = Mathf.Max(color.r, color.g, color.b);
|
||||||
|
if (max > 1f)
|
||||||
|
{
|
||||||
|
color.r /= max;
|
||||||
|
color.g /= max;
|
||||||
|
color.b /= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
color.a = 1f;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color WithAlpha(Color color, float alpha)
|
||||||
|
{
|
||||||
|
color.a = Mathf.Clamp01(alpha);
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DestroyRuntimeObject(Object target)
|
||||||
|
{
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
Destroy(target);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DestroyImmediate(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0f4ab89ec5474db497559af138ed0a6f
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
|
||||||
|
namespace VRBeats
|
||||||
|
{
|
||||||
|
public class SliceTrailEffect : MonoBehaviour
|
||||||
|
{
|
||||||
|
private const float Lifetime = 0.24f;
|
||||||
|
private const int PointCount = 7;
|
||||||
|
|
||||||
|
private LineRenderer glowLine = null;
|
||||||
|
private LineRenderer coreLine = null;
|
||||||
|
private Vector3 center = Vector3.zero;
|
||||||
|
private Vector3 tangent = Vector3.right;
|
||||||
|
private Vector3 lift = Vector3.up;
|
||||||
|
private Color effectColor = Color.cyan;
|
||||||
|
|
||||||
|
public static void Spawn(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
|
||||||
|
{
|
||||||
|
GameObject effectObject = new GameObject("SliceTrailEffect");
|
||||||
|
SliceTrailEffect effect = effectObject.AddComponent<SliceTrailEffect>();
|
||||||
|
effect.Construct(position, hitDir, saberUp, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Construct(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
|
||||||
|
{
|
||||||
|
center = position;
|
||||||
|
transform.position = position;
|
||||||
|
effectColor = NormalizeColor(color);
|
||||||
|
|
||||||
|
tangent = saberUp.sqrMagnitude > 0.001f ? saberUp.normalized : Vector3.right;
|
||||||
|
lift = hitDir.sqrMagnitude > 0.001f ? hitDir.normalized : Vector3.up;
|
||||||
|
|
||||||
|
glowLine = CreateLine("Glow", 0.16f, 0.45f);
|
||||||
|
coreLine = CreateLine("Core", 0.045f, 0.95f);
|
||||||
|
StartCoroutine(Animate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private LineRenderer CreateLine(string name, float width, float alpha)
|
||||||
|
{
|
||||||
|
GameObject lineObject = new GameObject(name);
|
||||||
|
lineObject.transform.SetParent(transform, false);
|
||||||
|
|
||||||
|
LineRenderer line = lineObject.AddComponent<LineRenderer>();
|
||||||
|
line.positionCount = PointCount;
|
||||||
|
line.useWorldSpace = true;
|
||||||
|
line.alignment = LineAlignment.View;
|
||||||
|
line.textureMode = LineTextureMode.Stretch;
|
||||||
|
line.numCornerVertices = 8;
|
||||||
|
line.numCapVertices = 8;
|
||||||
|
line.shadowCastingMode = ShadowCastingMode.Off;
|
||||||
|
line.receiveShadows = false;
|
||||||
|
line.material = CreateMaterial();
|
||||||
|
line.widthMultiplier = width;
|
||||||
|
ApplyGradient(line, alpha);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator Animate()
|
||||||
|
{
|
||||||
|
float age = 0.0f;
|
||||||
|
while (age < Lifetime)
|
||||||
|
{
|
||||||
|
float t = age / Lifetime;
|
||||||
|
float length = Mathf.Lerp(0.72f, 1.45f, t);
|
||||||
|
float bend = Mathf.Lerp(0.12f, 0.34f, t);
|
||||||
|
float alpha = 1.0f - t;
|
||||||
|
|
||||||
|
UpdateLine(glowLine, length, bend, alpha * 0.45f);
|
||||||
|
UpdateLine(coreLine, length * 0.88f, bend * 0.55f, alpha * 0.95f);
|
||||||
|
|
||||||
|
age += Time.deltaTime;
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Destroy(gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateLine(LineRenderer line, float length, float bend, float alpha)
|
||||||
|
{
|
||||||
|
if (line == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < PointCount; i++)
|
||||||
|
{
|
||||||
|
float normalized = PointCount <= 1 ? 0.0f : (float)i / (PointCount - 1);
|
||||||
|
float offset = normalized - 0.5f;
|
||||||
|
float curve = Mathf.Sin(normalized * Mathf.PI) * bend;
|
||||||
|
line.SetPosition(i, center + tangent * (offset * length) + lift * curve);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyGradient(line, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Material CreateMaterial()
|
||||||
|
{
|
||||||
|
Shader shader = Shader.Find("Sprites/Default");
|
||||||
|
if (shader == null)
|
||||||
|
shader = Shader.Find("Unlit/Transparent");
|
||||||
|
|
||||||
|
Material material = new Material(shader);
|
||||||
|
material.name = "Runtime Slice Trail";
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyGradient(LineRenderer line, float alpha)
|
||||||
|
{
|
||||||
|
Color start = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
|
||||||
|
Color mid = new Color(effectColor.r, effectColor.g, effectColor.b, alpha);
|
||||||
|
Color end = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
|
||||||
|
|
||||||
|
Gradient gradient = new Gradient();
|
||||||
|
gradient.SetKeys(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new GradientColorKey(start, 0.0f),
|
||||||
|
new GradientColorKey(mid, 0.5f),
|
||||||
|
new GradientColorKey(end, 1.0f),
|
||||||
|
},
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new GradientAlphaKey(0.0f, 0.0f),
|
||||||
|
new GradientAlphaKey(alpha, 0.5f),
|
||||||
|
new GradientAlphaKey(0.0f, 1.0f),
|
||||||
|
});
|
||||||
|
line.colorGradient = gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color NormalizeColor(Color color)
|
||||||
|
{
|
||||||
|
float max = Mathf.Max(color.r, Mathf.Max(color.g, color.b));
|
||||||
|
if (max > 1.0f)
|
||||||
|
color /= max;
|
||||||
|
|
||||||
|
color.a = 1.0f;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 41e40f1c9d0c48af9e2ad90cb8c5108c
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -56,10 +56,12 @@ namespace VRBeats
|
|||||||
//notify to whoever is listening that the player did a correct/incorrect slice
|
//notify to whoever is listening that the player did a correct/incorrect slice
|
||||||
if ( IsCutIntentValid(info as BeatDamageInfo) )
|
if ( IsCutIntentValid(info as BeatDamageInfo) )
|
||||||
{
|
{
|
||||||
|
ScoreManager.ReportSliceTiming(GetTimingErrorSeconds());
|
||||||
onCorrectSlice.Invoke();
|
onCorrectSlice.Invoke();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
ScoreManager.ReportMiss();
|
||||||
onIncorrectSlice.Invoke();
|
onIncorrectSlice.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ namespace VRBeats
|
|||||||
|
|
||||||
public void Kill()
|
public void Kill()
|
||||||
{
|
{
|
||||||
|
ScoreManager.ReportMiss();
|
||||||
onPlayerMiss.Invoke();
|
onPlayerMiss.Invoke();
|
||||||
canBeKilled = false;
|
canBeKilled = false;
|
||||||
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate
|
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate
|
||||||
@@ -107,6 +110,13 @@ namespace VRBeats
|
|||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private float GetTimingErrorSeconds()
|
||||||
|
{
|
||||||
|
float speed = Mathf.Max(Mathf.Abs(thisSpawneable.Speed), 0.001f);
|
||||||
|
float distanceFromPlayer = Mathf.Abs(transform.position.z - player.position.z);
|
||||||
|
return distanceFromPlayer / speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using UnityEngine;
|
using System.Collections;
|
||||||
|
using UnityEngine;
|
||||||
using Platinio;
|
using Platinio;
|
||||||
using UnityEngine.Playables;
|
using UnityEngine.Playables;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
using VRBeats.ScriptableEvents;
|
using VRBeats.ScriptableEvents;
|
||||||
using VRSDK;
|
using VRSDK;
|
||||||
|
|
||||||
@@ -60,24 +62,22 @@ namespace VRBeats
|
|||||||
Vector3 finalPosition = CalculateSpawnPosition( info.position);
|
Vector3 finalPosition = CalculateSpawnPosition( info.position);
|
||||||
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
|
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
|
||||||
Vector3 spawnPosition = finalPosition - travelOffset;
|
Vector3 spawnPosition = finalPosition - travelOffset;
|
||||||
|
|
||||||
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
|
|
||||||
SetSpeedRelativeToPlayZone(info);
|
|
||||||
clone.Construct(info);
|
|
||||||
|
|
||||||
Vector3 finalScale = clone.transform.localScale;
|
|
||||||
clone.transform.localScale = Vector3.zero;
|
|
||||||
|
|
||||||
float travelTime = info.travelTimeOverride > 0f ? info.travelTimeOverride : settings.TargetTravelTime;
|
float travelTime = info.travelTimeOverride > 0f ? info.travelTimeOverride : settings.TargetTravelTime;
|
||||||
|
|
||||||
clone.transform.Move(finalPosition, travelTime).SetEase(settings.TargetTravelEase).SetOnComplete(delegate
|
info.speed = settings.TargetTravelDistance / Mathf.Max(0.05f, travelTime);
|
||||||
|
SetSpeedRelativeToPlayZone(info);
|
||||||
|
|
||||||
|
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
|
||||||
|
clone.Construct(info);
|
||||||
|
StartCoroutine(BeginContinuousSpawnNextFrame(clone));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator BeginContinuousSpawnNextFrame(Spawneable clone)
|
||||||
{
|
{
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
if (clone != null)
|
||||||
clone.OnSpawn();
|
clone.OnSpawn();
|
||||||
}).SetUpdateMode(Platinio.TweenEngine.UpdateMode.Update);
|
|
||||||
|
|
||||||
|
|
||||||
clone.transform.ScaleTween(finalScale, travelTime).SetEase(settings.TargetTravelEase);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
|
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
|
||||||
@@ -122,13 +122,7 @@ namespace VRBeats
|
|||||||
|
|
||||||
public void RestartLevel()
|
public void RestartLevel()
|
||||||
{
|
{
|
||||||
gameObject.CancelAllTweens();
|
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
|
||||||
|
|
||||||
isGameRunning = true;
|
|
||||||
audioManager.SetAudioMixerPitch(1.0f);
|
|
||||||
enviromentController.TurnLightsOn();
|
|
||||||
playableDirector.time = 0.0f;
|
|
||||||
playableDirector.Play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ namespace VRBeats
|
|||||||
public void EnableXRRayInteractorComponents()
|
public void EnableXRRayInteractorComponents()
|
||||||
{
|
{
|
||||||
if (rayInteractor != null)
|
if (rayInteractor != null)
|
||||||
rayInteractor.enabled = true;
|
rayInteractor.enabled = false;
|
||||||
if (interactorLineVisual != null)
|
if (interactorLineVisual != null)
|
||||||
interactorLineVisual.enabled = true;
|
interactorLineVisual.enabled = false;
|
||||||
if (lineRender != null)
|
if (lineRender != null)
|
||||||
lineRender.enabled = true;
|
lineRender.enabled = true;
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ namespace VRBeats
|
|||||||
private VR_Grabbable grabbable = null;
|
private VR_Grabbable grabbable = null;
|
||||||
private ColorSide colorSide = ColorSide.Left;
|
private ColorSide colorSide = ColorSide.Left;
|
||||||
private MeshRenderer[] renderArray = null;
|
private MeshRenderer[] renderArray = null;
|
||||||
|
private SaberTrailEffect trailEffect = null;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
renderArray = transform.GetComponentsInChildren<MeshRenderer>();
|
renderArray = transform.GetComponentsInChildren<MeshRenderer>();
|
||||||
|
trailEffect = GetComponent<SaberTrailEffect>();
|
||||||
|
if (trailEffect == null)
|
||||||
|
trailEffect = gameObject.AddComponent<SaberTrailEffect>();
|
||||||
|
|
||||||
grabbable = GetComponent<VR_Grabbable>();
|
grabbable = GetComponent<VR_Grabbable>();
|
||||||
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
|
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
|
||||||
@@ -44,6 +48,9 @@ namespace VRBeats
|
|||||||
{
|
{
|
||||||
SetMaterialBindings(materialBindingArray[n], c);
|
SetMaterialBindings(materialBindingArray[n], c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trailEffect != null)
|
||||||
|
trailEffect.SetColor(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetMaterialBindings(MaterialBindings matBindings, Color c)
|
private void SetMaterialBindings(MaterialBindings matBindings, Color c)
|
||||||
@@ -55,11 +62,15 @@ namespace VRBeats
|
|||||||
public void MakeVisible()
|
public void MakeVisible()
|
||||||
{
|
{
|
||||||
SetRenderArrayEnableValue(true);
|
SetRenderArrayEnableValue(true);
|
||||||
|
if (trailEffect != null)
|
||||||
|
trailEffect.SetVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MakeInvisible()
|
public void MakeInvisible()
|
||||||
{
|
{
|
||||||
SetRenderArrayEnableValue(false);
|
SetRenderArrayEnableValue(false);
|
||||||
|
if (trailEffect != null)
|
||||||
|
trailEffect.SetVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetRenderArrayEnableValue(bool value)
|
private void SetRenderArrayEnableValue(bool value)
|
||||||
|
|||||||
@@ -13,24 +13,42 @@ namespace VRBeats
|
|||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
if (colorSide == ColorSide.Left) controller = VR_Manager.instance.Player.LeftController;
|
ResolveController();
|
||||||
if (colorSide == ColorSide.Right) controller = VR_Manager.instance.Player.RightController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
|
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
|
||||||
{
|
{
|
||||||
|
ResolveController();
|
||||||
|
|
||||||
var damageInfo = base.CreateDamageInfo(hitPoint);
|
var damageInfo = base.CreateDamageInfo(hitPoint);
|
||||||
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo);
|
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo);
|
||||||
|
|
||||||
Vector3 controllerVelocity = controller.Velocity;
|
Vector3 controllerVelocity = controller != null ? controller.Velocity : Vector3.zero;
|
||||||
|
|
||||||
beatDamageInfo.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
|
beatDamageInfo.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
|
||||||
beatDamageInfo.hitObject = gameObject;
|
beatDamageInfo.hitObject = gameObject;
|
||||||
beatDamageInfo.colorSide = colorSide;
|
beatDamageInfo.colorSide = colorSide;
|
||||||
beatDamageInfo.velocity = controller.Velocity.magnitude;
|
beatDamageInfo.velocity = controllerVelocity.magnitude;
|
||||||
|
|
||||||
return beatDamageInfo;
|
return beatDamageInfo;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private void ResolveController()
|
||||||
|
{
|
||||||
|
VR_Grabbable grabbable = GetComponent<VR_Grabbable>();
|
||||||
|
if (grabbable != null && grabbable.GrabController != null)
|
||||||
|
{
|
||||||
|
controller = grabbable.GrabController;
|
||||||
|
colorSide = controller.ControllerType == VR_ControllerType.Right ? ColorSide.Right : ColorSide.Left;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (VR_Manager.instance == null || VR_Manager.instance.Player == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
controller = colorSide == ColorSide.Left
|
||||||
|
? VR_Manager.instance.Player.LeftController
|
||||||
|
: VR_Manager.instance.Player.RightController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ namespace VRBeats
|
|||||||
public Vector3 rotation = Vector3.zero;
|
public Vector3 rotation = Vector3.zero;
|
||||||
public float speed = 2.0f;
|
public float speed = 2.0f;
|
||||||
public int speedMultiplier = 1;
|
public int speedMultiplier = 1;
|
||||||
// 0 이면 Settings.TargetTravelTime 사용, 양수면 해당 시간으로 이동
|
// 0이면 Settings.TargetTravelTime 사용, 양수면 해당 시간 동안 일정 속도로 이동
|
||||||
public float travelTimeOverride = 0f;
|
public float travelTimeOverride = 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,29 @@ namespace VRBeats
|
|||||||
}
|
}
|
||||||
|
|
||||||
scoreManager = FindFirstObjectByType<ScoreManager>();
|
scoreManager = FindFirstObjectByType<ScoreManager>();
|
||||||
|
ApplyPopupTextStyle();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowScore()
|
public void ShowScore()
|
||||||
{
|
{
|
||||||
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , scoreFadeTime).SetOnUpdateFloat(delegate (float v)
|
if (scoreText == null || scoreManager == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , Mathf.Min(scoreFadeTime, 0.8f)).SetOnUpdateFloat(delegate (float v)
|
||||||
{
|
{
|
||||||
SetScore( (int)v );
|
SetScore( (int)v );
|
||||||
|
}).SetOnComplete(delegate
|
||||||
|
{
|
||||||
|
scoreText.text = scoreManager.BuildResultSummary(length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetValues()
|
public void ResetValues()
|
||||||
{
|
{
|
||||||
gameObject.CancelAllTweens();
|
gameObject.CancelAllTweens();
|
||||||
|
ApplyPopupTextStyle();
|
||||||
|
if (scoreText != null)
|
||||||
scoreText.text = initialValue;
|
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 UnityEngine.UI;
|
||||||
using Platinio.TweenEngine;
|
using Platinio.TweenEngine;
|
||||||
using VRBeats.ScriptableEvents;
|
using VRBeats.ScriptableEvents;
|
||||||
@@ -7,6 +7,14 @@ namespace VRBeats
|
|||||||
{
|
{
|
||||||
public class ScoreManager : MonoBehaviour
|
public class ScoreManager : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
private enum BeatJudgement
|
||||||
|
{
|
||||||
|
Perfect,
|
||||||
|
Great,
|
||||||
|
Good,
|
||||||
|
Miss
|
||||||
|
}
|
||||||
|
|
||||||
[SerializeField] private Text multiplierLabel = null;
|
[SerializeField] private Text multiplierLabel = null;
|
||||||
[SerializeField] private Text scoreLabel = null;
|
[SerializeField] private Text scoreLabel = null;
|
||||||
[SerializeField] private Image multiplierLoader = null;
|
[SerializeField] private Image multiplierLoader = null;
|
||||||
@@ -14,39 +22,134 @@ namespace VRBeats
|
|||||||
[SerializeField] private CanvasGroup canvasGroup = null;
|
[SerializeField] private CanvasGroup canvasGroup = null;
|
||||||
[SerializeField] private GameEvent onGameOver = null;
|
[SerializeField] private GameEvent onGameOver = null;
|
||||||
|
|
||||||
|
[Header("DJMAX Style Score")]
|
||||||
|
[SerializeField] private Text comboLabel = null;
|
||||||
|
[SerializeField] private Text accuracyLabel = null;
|
||||||
|
[SerializeField] private Text judgementLabel = null;
|
||||||
|
[SerializeField] private bool createMissingHudLabels = true;
|
||||||
|
[SerializeField] private bool applyHudPlacement = true;
|
||||||
|
[SerializeField] private Vector2 hudAnchoredPosition = new Vector2(0.0f, 1.65f);
|
||||||
|
[SerializeField] private float perfectWindow = 0.08f;
|
||||||
|
[SerializeField] private float greatWindow = 0.15f;
|
||||||
|
[SerializeField] private float goodWindow = 0.25f;
|
||||||
|
|
||||||
private int maxMultiplier = 0;
|
private int maxMultiplier = 0;
|
||||||
private int scorePerHit = 0;
|
private const int MaxCourseScore = 1000000;
|
||||||
private int currentScore = 0;
|
private float currentMultiplier = 1.0f;
|
||||||
private int currentMultiplier = 0;
|
|
||||||
private int toNextMultiplierIncrease = 2;
|
|
||||||
private int acumulateCorrectSlices = 0;
|
|
||||||
private int acumulateErrors = 0;
|
private int acumulateErrors = 0;
|
||||||
private int errorLimit = 0;
|
private int errorLimit = 0;
|
||||||
|
private int totalNoteCount = 0;
|
||||||
|
private int judgedNoteCount = 0;
|
||||||
|
private int currentCombo = 0;
|
||||||
|
private int maxCombo = 0;
|
||||||
|
private int perfectCount = 0;
|
||||||
|
private int greatCount = 0;
|
||||||
|
private int goodCount = 0;
|
||||||
|
private int missCount = 0;
|
||||||
|
private int earnedAccuracyPoints = 0;
|
||||||
private float visualScore = 0.0f;
|
private float visualScore = 0.0f;
|
||||||
private int scoreTweenID = -1;
|
private int scoreTweenID = -1;
|
||||||
private int loaderTweenID = -1;
|
private int loaderTweenID = -1;
|
||||||
|
private BeatJudgement lastJudgement = BeatJudgement.Perfect;
|
||||||
|
private float judgementTimer = 0.0f;
|
||||||
|
private Text progressLabel = null;
|
||||||
|
private Text rankLabel = null;
|
||||||
|
private Vector3 comboBaseScale = Vector3.one;
|
||||||
|
private float songCurrentTime = 0.0f;
|
||||||
|
private float songDuration = 0.0f;
|
||||||
|
private bool resultFinalized = false;
|
||||||
private bool destroyed = false;
|
private bool destroyed = false;
|
||||||
|
|
||||||
public int CurrentScore
|
private static bool hasPendingSliceTiming = false;
|
||||||
|
private static float pendingSliceTiming = 0.0f;
|
||||||
|
|
||||||
|
public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f);
|
||||||
|
public float AccuracyPercent
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
return currentScore;
|
int denominatorNotes = totalNoteCount > 0 ? totalNoteCount : judgedNoteCount;
|
||||||
|
if (denominatorNotes <= 0)
|
||||||
|
return 100.0f;
|
||||||
|
|
||||||
|
return (float)earnedAccuracyPoints / (denominatorNotes * 1000) * 100.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Rank
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
float accuracy = AccuracyPercent;
|
||||||
|
if (accuracy >= 98.0f) return "S+";
|
||||||
|
if (accuracy >= 95.0f) return "S";
|
||||||
|
if (accuracy >= 90.0f) return "A";
|
||||||
|
if (accuracy >= 80.0f) return "B";
|
||||||
|
if (accuracy >= 70.0f) return "C";
|
||||||
|
if (accuracy >= 60.0f) return "D";
|
||||||
|
return "F";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
|
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
|
||||||
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
|
|
||||||
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
|
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
|
||||||
|
|
||||||
|
if (multiplierLoader != null)
|
||||||
multiplierLoader.fillAmount = 0.0f;
|
multiplierLoader.fillAmount = 0.0f;
|
||||||
|
|
||||||
|
PrepareHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ReportSliceTiming(float timingErrorSeconds)
|
||||||
|
{
|
||||||
|
pendingSliceTiming = timingErrorSeconds;
|
||||||
|
hasPendingSliceTiming = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ReportMiss()
|
||||||
|
{
|
||||||
|
hasPendingSliceTiming = false;
|
||||||
|
pendingSliceTiming = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTotalNotes(int noteCount)
|
||||||
|
{
|
||||||
|
totalNoteCount = Mathf.Max(0, noteCount);
|
||||||
|
resultFinalized = false;
|
||||||
|
UpdateScoreTween();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteSong()
|
||||||
|
{
|
||||||
|
if (resultFinalized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
resultFinalized = true;
|
||||||
|
|
||||||
|
int missedUnjudgedNotes = Mathf.Max(0, totalNoteCount - judgedNoteCount);
|
||||||
|
if (missedUnjudgedNotes > 0)
|
||||||
|
{
|
||||||
|
missCount += missedUnjudgedNotes;
|
||||||
|
judgedNoteCount += missedUnjudgedNotes;
|
||||||
|
currentCombo = 0;
|
||||||
|
currentMultiplier = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateScoreTween();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSongProgress(float currentTime, float duration)
|
||||||
|
{
|
||||||
|
songCurrentTime = Mathf.Max(0.0f, currentTime);
|
||||||
|
songDuration = Mathf.Max(0.0f, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnGameOver()
|
public void OnGameOver()
|
||||||
{
|
{
|
||||||
gameObject.CancelAllTweens();
|
gameObject.CancelAllTweens();
|
||||||
|
if (canvasGroup != null)
|
||||||
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
|
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,21 +157,32 @@ namespace VRBeats
|
|||||||
{
|
{
|
||||||
ResetThisComponent();
|
ResetThisComponent();
|
||||||
gameObject.CancelAllTweens();
|
gameObject.CancelAllTweens();
|
||||||
|
if (canvasGroup != null)
|
||||||
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
|
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetThisComponent()
|
public void ResetThisComponent()
|
||||||
{
|
{
|
||||||
currentMultiplier = 0;
|
currentMultiplier = 1.0f;
|
||||||
currentScore = 0;
|
|
||||||
acumulateCorrectSlices = 0;
|
|
||||||
visualScore = 0;
|
visualScore = 0;
|
||||||
acumulateErrors = 0;
|
acumulateErrors = 0;
|
||||||
toNextMultiplierIncrease = 2;
|
judgedNoteCount = 0;
|
||||||
|
currentCombo = 0;
|
||||||
|
maxCombo = 0;
|
||||||
|
perfectCount = 0;
|
||||||
|
greatCount = 0;
|
||||||
|
goodCount = 0;
|
||||||
|
missCount = 0;
|
||||||
|
earnedAccuracyPoints = 0;
|
||||||
|
judgementTimer = 0.0f;
|
||||||
|
resultFinalized = false;
|
||||||
|
hasPendingSliceTiming = false;
|
||||||
|
pendingSliceTiming = 0.0f;
|
||||||
|
|
||||||
|
if (multiplierLoader != null)
|
||||||
|
multiplierLoader.fillAmount = 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
UpdateUI();
|
UpdateUI();
|
||||||
@@ -79,24 +193,44 @@ namespace VRBeats
|
|||||||
if (destroyed)
|
if (destroyed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
acumulateErrors = 0;
|
BeatJudgement judgement = ConsumeJudgement();
|
||||||
acumulateCorrectSlices++;
|
RegisterJudgement(judgement);
|
||||||
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
|
|
||||||
|
|
||||||
CancelTweenById(scoreTweenID);
|
acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
|
||||||
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
|
|
||||||
{
|
|
||||||
visualScore = value;
|
|
||||||
}).ID;
|
|
||||||
|
|
||||||
|
UpdateScoreTween();
|
||||||
UpdateMultiplierLoaderValue();
|
UpdateMultiplierLoaderValue();
|
||||||
|
|
||||||
if (acumulateCorrectSlices >= toNextMultiplierIncrease)
|
|
||||||
{
|
|
||||||
IncreaseMultiplier();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnIncorrectSlice()
|
||||||
|
{
|
||||||
|
if (destroyed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RegisterJudgement(BeatJudgement.Miss);
|
||||||
|
acumulateErrors++;
|
||||||
|
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)
|
private void CancelTweenById(int id)
|
||||||
@@ -107,37 +241,29 @@ namespace VRBeats
|
|||||||
|
|
||||||
private void UpdateMultiplierLoaderValue()
|
private void UpdateMultiplierLoaderValue()
|
||||||
{
|
{
|
||||||
if (destroyed)
|
if (destroyed || multiplierLoader == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
float multiplierLoaderValue = (float)acumulateCorrectSlices / (float)toNextMultiplierIncrease;
|
float multiplierLoaderValue = GetComboTierProgress();
|
||||||
|
|
||||||
|
|
||||||
CancelTweenById(loaderTweenID);
|
CancelTweenById(loaderTweenID);
|
||||||
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
|
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f)
|
||||||
|
.SetEase(Ease.EaseOutExpo)
|
||||||
|
.SetOnUpdateFloat(delegate (float value)
|
||||||
{
|
{
|
||||||
if (multiplierLoader != null)
|
if (multiplierLoader != null)
|
||||||
multiplierLoader.fillAmount = value;
|
multiplierLoader.fillAmount = value;
|
||||||
}).SetOwner(multiplierLoader.gameObject).ID;
|
})
|
||||||
|
.SetOwner(multiplierLoader.gameObject)
|
||||||
|
.ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnIncorrectSlice()
|
private void UpdateScoreTween()
|
||||||
{
|
{
|
||||||
if (destroyed)
|
CancelTweenById(scoreTweenID);
|
||||||
return;
|
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, CurrentScore, scoreFollowTime)
|
||||||
|
.SetEase(Ease.EaseOutExpo)
|
||||||
acumulateErrors++;
|
.SetOnUpdateFloat(delegate (float value) { visualScore = value; })
|
||||||
acumulateCorrectSlices = 0;
|
.ID;
|
||||||
currentMultiplier = 0;
|
|
||||||
toNextMultiplierIncrease = 2;
|
|
||||||
|
|
||||||
UpdateMultiplierLoaderValue();
|
|
||||||
|
|
||||||
if (acumulateErrors > errorLimit)
|
|
||||||
{
|
|
||||||
onGameOver.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateUI()
|
private void UpdateUI()
|
||||||
@@ -145,41 +271,230 @@ namespace VRBeats
|
|||||||
if (destroyed)
|
if (destroyed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
multiplierLabel.text = currentMultiplier.ToString();
|
if (multiplierLabel != null)
|
||||||
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString();
|
multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}";
|
||||||
}
|
if (scoreLabel != null)
|
||||||
|
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
|
||||||
|
if (comboLabel != null)
|
||||||
|
comboLabel.text = currentCombo > 0
|
||||||
|
? $"<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>{currentCombo}</size>"
|
||||||
|
: "<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>0</size>";
|
||||||
|
if (accuracyLabel != null)
|
||||||
|
accuracyLabel.text = $"{AccuracyPercent:0.0}%";
|
||||||
|
if (rankLabel != null)
|
||||||
|
rankLabel.text = $"<color={GetRankColorHex()}>{Rank}</color>";
|
||||||
|
if (progressLabel != null)
|
||||||
|
progressLabel.text = songDuration > 0.0f
|
||||||
|
? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}"
|
||||||
|
: "";
|
||||||
|
|
||||||
private void IncreaseMultiplier()
|
if (judgementLabel == null)
|
||||||
{
|
|
||||||
if (destroyed)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
acumulateCorrectSlices = 0;
|
judgementTimer -= Time.deltaTime;
|
||||||
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier );
|
judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : "";
|
||||||
toNextMultiplierIncrease = (currentMultiplier + 1) * 2;
|
judgementLabel.color = GetJudgementColor(lastJudgement);
|
||||||
|
|
||||||
PlatinioTween.instance.CancelTween(multiplierLoader.gameObject);
|
|
||||||
|
|
||||||
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 1.0f, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
|
|
||||||
{
|
|
||||||
if(multiplierLoader != null)
|
|
||||||
multiplierLoader.fillAmount = value;
|
|
||||||
}).SetOwner(multiplierLoader.gameObject).SetOnComplete( delegate
|
|
||||||
{
|
|
||||||
if (multiplierLabel != null)
|
|
||||||
{
|
|
||||||
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
|
|
||||||
{
|
|
||||||
if (multiplierLoader != null)
|
|
||||||
multiplierLoader.fillAmount = value;
|
|
||||||
}).SetOwner(multiplierLoader.gameObject);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} );
|
private BeatJudgement ConsumeJudgement()
|
||||||
|
{
|
||||||
|
if (!hasPendingSliceTiming)
|
||||||
|
return BeatJudgement.Perfect;
|
||||||
|
|
||||||
|
float timing = pendingSliceTiming;
|
||||||
|
hasPendingSliceTiming = false;
|
||||||
|
pendingSliceTiming = 0.0f;
|
||||||
|
|
||||||
|
if (timing <= perfectWindow) return BeatJudgement.Perfect;
|
||||||
|
if (timing <= greatWindow) return BeatJudgement.Great;
|
||||||
|
if (timing <= goodWindow) return BeatJudgement.Good;
|
||||||
|
return BeatJudgement.Good;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RegisterJudgement(BeatJudgement judgement)
|
||||||
|
{
|
||||||
|
lastJudgement = judgement;
|
||||||
|
judgementTimer = 0.45f;
|
||||||
|
judgedNoteCount++;
|
||||||
|
|
||||||
|
if (judgement == BeatJudgement.Perfect)
|
||||||
|
{
|
||||||
|
perfectCount++;
|
||||||
|
earnedAccuracyPoints += 1000;
|
||||||
|
currentCombo++;
|
||||||
|
}
|
||||||
|
else if (judgement == BeatJudgement.Great)
|
||||||
|
{
|
||||||
|
greatCount++;
|
||||||
|
earnedAccuracyPoints += 700;
|
||||||
|
currentCombo++;
|
||||||
|
}
|
||||||
|
else if (judgement == BeatJudgement.Good)
|
||||||
|
{
|
||||||
|
goodCount++;
|
||||||
|
earnedAccuracyPoints += 400;
|
||||||
|
currentCombo++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
missCount++;
|
||||||
|
currentCombo = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxCombo = Mathf.Max(maxCombo, currentCombo);
|
||||||
|
currentMultiplier = judgement == BeatJudgement.Miss
|
||||||
|
? 1.0f
|
||||||
|
: Mathf.Min(GetComboMultiplier(currentCombo), Mathf.Max(1.0f, maxMultiplier));
|
||||||
|
|
||||||
|
PulseComboLabel(judgement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static float GetComboMultiplier(int combo)
|
||||||
|
{
|
||||||
|
if (combo >= 200) return 1.5f;
|
||||||
|
if (combo >= 100) return 1.35f;
|
||||||
|
if (combo >= 50) return 1.2f;
|
||||||
|
if (combo >= 20) return 1.1f;
|
||||||
|
return 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetJudgementText(BeatJudgement judgement)
|
||||||
|
{
|
||||||
|
switch (judgement)
|
||||||
|
{
|
||||||
|
case BeatJudgement.Perfect: return "PERFECT";
|
||||||
|
case BeatJudgement.Great: return "GREAT";
|
||||||
|
case BeatJudgement.Good: return "GOOD";
|
||||||
|
default: return "BREAK";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetJudgementColor(BeatJudgement judgement)
|
||||||
|
{
|
||||||
|
switch (judgement)
|
||||||
|
{
|
||||||
|
case BeatJudgement.Perfect: return new Color(0.25f, 0.95f, 1.0f, 1.0f);
|
||||||
|
case BeatJudgement.Great: return new Color(0.58f, 1.0f, 0.45f, 1.0f);
|
||||||
|
case BeatJudgement.Good: return new Color(1.0f, 0.8f, 0.35f, 1.0f);
|
||||||
|
default: return new Color(1.0f, 0.25f, 0.45f, 1.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float GetComboTierProgress()
|
||||||
|
{
|
||||||
|
int lower = 0;
|
||||||
|
int upper = 20;
|
||||||
|
if (currentCombo >= 200) return 1.0f;
|
||||||
|
if (currentCombo >= 100) { lower = 100; upper = 200; }
|
||||||
|
else if (currentCombo >= 50) { lower = 50; upper = 100; }
|
||||||
|
else if (currentCombo >= 20) { lower = 20; upper = 50; }
|
||||||
|
|
||||||
|
return Mathf.InverseLerp(lower, upper, currentCombo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrepareHud()
|
||||||
|
{
|
||||||
|
RectTransform rect = transform as RectTransform;
|
||||||
|
if (applyHudPlacement && rect != null)
|
||||||
|
rect.anchoredPosition = hudAnchoredPosition;
|
||||||
|
|
||||||
|
ConfigureText(scoreLabel, new Vector2(-255.0f, -18.0f), new Vector2(220.0f, 40.0f), 26, Color.white, TextAnchor.MiddleLeft);
|
||||||
|
ConfigureText(multiplierLabel, new Vector2(255.0f, 36.0f), new Vector2(100.0f, 68.0f), 34, Color.white, TextAnchor.MiddleCenter);
|
||||||
|
ConfigureImage(multiplierLoader, new Vector2(255.0f, 36.0f), new Vector2(104.0f, 104.0f));
|
||||||
|
|
||||||
|
if (!createMissingHudLabels)
|
||||||
|
return;
|
||||||
|
|
||||||
|
comboLabel ??= CreateHudText("Combo", new Vector2(-255.0f, 74.0f), new Vector2(220.0f, 112.0f), 36, Color.white, TextAnchor.MiddleLeft);
|
||||||
|
accuracyLabel ??= CreateHudText("Accuracy", new Vector2(-255.0f, -58.0f), new Vector2(220.0f, 34.0f), 20, new Color(0.88f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleLeft);
|
||||||
|
rankLabel ??= CreateHudText("Rank", new Vector2(-255.0f, -105.0f), new Vector2(220.0f, 76.0f), 48, Color.white, TextAnchor.MiddleLeft);
|
||||||
|
judgementLabel ??= CreateHudText("Judgement", new Vector2(0.0f, 112.0f), new Vector2(260.0f, 54.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter);
|
||||||
|
progressLabel ??= CreateHudText("SongProgress", new Vector2(255.0f, -62.0f), new Vector2(180.0f, 34.0f), 18, Color.white, TextAnchor.MiddleCenter);
|
||||||
|
comboBaseScale = comboLabel != null ? comboLabel.transform.localScale : Vector3.one;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Text CreateHudText(string name, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
|
||||||
|
{
|
||||||
|
GameObject textObject = new GameObject(name);
|
||||||
|
textObject.layer = gameObject.layer;
|
||||||
|
textObject.transform.SetParent(transform, false);
|
||||||
|
|
||||||
|
RectTransform rect = textObject.AddComponent<RectTransform>();
|
||||||
|
textObject.AddComponent<CanvasRenderer>();
|
||||||
|
Text text = textObject.AddComponent<Text>();
|
||||||
|
ConfigureText(text, anchoredPosition, size, fontSize, color, alignment);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureText(Text text, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
|
||||||
|
{
|
||||||
|
if (text == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RectTransform rect = text.rectTransform;
|
||||||
|
rect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||||
|
rect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||||
|
rect.anchoredPosition = anchoredPosition;
|
||||||
|
rect.sizeDelta = size;
|
||||||
|
|
||||||
|
text.fontSize = fontSize;
|
||||||
|
text.color = color;
|
||||||
|
text.alignment = alignment;
|
||||||
|
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||||
|
text.verticalOverflow = VerticalWrapMode.Overflow;
|
||||||
|
text.raycastTarget = false;
|
||||||
|
text.supportRichText = true;
|
||||||
|
text.lineSpacing = 0.86f;
|
||||||
|
|
||||||
|
Shadow shadow = text.GetComponent<Shadow>() ?? text.gameObject.AddComponent<Shadow>();
|
||||||
|
shadow.effectColor = new Color(0.0f, 0.0f, 0.0f, 0.72f);
|
||||||
|
shadow.effectDistance = new Vector2(3.0f, -3.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureImage(Image image, Vector2 anchoredPosition, Vector2 size)
|
||||||
|
{
|
||||||
|
if (image == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RectTransform rect = image.rectTransform;
|
||||||
|
rect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||||
|
rect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||||
|
rect.anchoredPosition = anchoredPosition;
|
||||||
|
rect.sizeDelta = size;
|
||||||
|
image.color = new Color(1.0f, 1.0f, 1.0f, 0.85f);
|
||||||
|
image.raycastTarget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRankColorHex()
|
||||||
|
{
|
||||||
|
switch (Rank)
|
||||||
|
{
|
||||||
|
case "S+": return "#41F2FF";
|
||||||
|
case "S": return "#69FFD1";
|
||||||
|
case "A": return "#B9FF72";
|
||||||
|
case "B": return "#FFE06A";
|
||||||
|
case "C": return "#FFB15C";
|
||||||
|
case "D": return "#FF7C7C";
|
||||||
|
default: return "#A9B7C0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PulseComboLabel(BeatJudgement judgement)
|
||||||
|
{
|
||||||
|
if (comboLabel == null || judgement == BeatJudgement.Miss)
|
||||||
|
return;
|
||||||
|
|
||||||
|
comboLabel.gameObject.CancelAllTweens();
|
||||||
|
comboLabel.transform.localScale = comboBaseScale * 1.08f;
|
||||||
|
comboLabel.transform.ScaleTween(comboBaseScale, 0.16f).SetEase(Ease.EaseOutExpo).SetOwner(comboLabel.gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTime(float seconds)
|
||||||
|
{
|
||||||
|
int wholeSeconds = Mathf.Max(0, Mathf.FloorToInt(seconds));
|
||||||
|
int minutes = wholeSeconds / 60;
|
||||||
|
int remainingSeconds = wholeSeconds % 60;
|
||||||
|
return $"{minutes}:{remainingSeconds:00}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
rightColor: {r: 0, g: 0.6002884, b: 1, a: 1}
|
rightColor: {r: 0, g: 0.6002884, b: 1, a: 1}
|
||||||
leftColor: {r: 1, g: 0, b: 0, a: 1}
|
leftColor: {r: 1, g: 0, b: 0, a: 1}
|
||||||
glowIntensity: 100
|
glowIntensity: 40
|
||||||
targetTravelDistance: 40
|
targetTravelDistance: 40
|
||||||
targetTravelTime: 1.8
|
targetTravelTime: 3.2
|
||||||
targetTravelEase: 19
|
targetTravelEase: 19
|
||||||
errorLimit: 7
|
errorLimit: 7
|
||||||
scorePerHit: 50
|
scorePerHit: 50
|
||||||
|
|||||||
Binary file not shown.
@@ -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`
|
- Unity 버전: `6000.3.12f1`
|
||||||
- 현재 브랜치: `main`
|
- 현재 브랜치: `master`
|
||||||
- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git`
|
- 원격 저장소: `origin` = `https://whdwo798.synology.me/whdwo798/BeatSaber.git`
|
||||||
- 최근 푸시 커밋: `182d2c9 fix: stabilize VR UI and song playback`
|
|
||||||
- `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개
|
- `dotnet build VRBeatSaber.slnx --no-incremental` 결과: 오류 0개, 경고 0개
|
||||||
- 현재 워킹트리에는 큐브 간격 보정과 경고 제거 작업이 커밋 전 변경으로 남아 있다.
|
- NAS 비밀번호/세션 파일은 로컬 전용이다. `env`, `cookies.txt`, `Assets/StreamingAssets/nas_config.json`, Unity `_Recovery`는 커밋하지 않는다.
|
||||||
|
- Unity Codex Bridge는 에디터 내부 HTTP 브리지와 MCP 서버를 통해 씬/로그/캡처를 조회하는 용도다. 포트 충돌 시 Unity 재시작 또는 기존 포트 점유 프로세스 정리가 필요하다.
|
||||||
|
|
||||||
### 실제 씬 구성
|
### 실제 씬 구성
|
||||||
|
|
||||||
@@ -52,7 +52,76 @@ SongCreator.unity
|
|||||||
-> NasPublisher가 mp3, map json, songs.json을 Synology NAS에 업로드
|
-> NasPublisher가 mp3, map json, songs.json을 Synology NAS에 업로드
|
||||||
```
|
```
|
||||||
|
|
||||||
### 최근 반영된 변경
|
### 2026-05-28 최근 반영된 변경
|
||||||
|
|
||||||
|
#### 게임 플레이/노트/점수
|
||||||
|
|
||||||
|
- `Assets/Script/SongController.cs`
|
||||||
|
- 큐브가 중간에 멈추거나 급가속하는 느낌을 줄이기 위해 노트를 일정 속도로 접근시키는 흐름으로 조정했다.
|
||||||
|
- 노트 도착 기준을 오디오 시간과 맞추고, 난이도별 노트 수가 적어도 랭크가 과도하게 낮게 나오지 않도록 총 노트 기준 점수/랭크 구조와 맞물리게 했다.
|
||||||
|
- `Assets/VRBeatsKit/Scripts/UI/ScoreManager.cs`, `FinalScoreLabel.cs`
|
||||||
|
- DJMAX 계열에 가까운 콤보/정확도 중심 점수 구조로 개편했다.
|
||||||
|
- 총 노트 수 기준으로 정확도와 랭크를 계산하므로 Normal처럼 노트 수가 적은 곡도 구조적으로 F에 고정되지 않는다.
|
||||||
|
- 재시작/리플레이 시 이전 점수, 콤보, 음악 상태가 남지 않도록 초기화 흐름을 보강했다.
|
||||||
|
- `Assets/VRBeatsKit/Scripts/Core/VR_BeatCube.cs`, `Cuttable.cs`, `DamageSaber.cs`
|
||||||
|
- 정상 절단된 큐브가 뒤로 날아간 뒤 Miss로 다시 잡히는 문제를 막기 위해 절단/미스 상태 흐름을 보강했다.
|
||||||
|
- 방향/색상/속도 판정과 절단 시각 효과가 엇갈리지 않도록 유효 절단 조건을 정리했다.
|
||||||
|
- `Assets/VRBeatsKit/Scripts/Spawneable/SpawnEventInfo.cs`, `VR_BeatManager.cs`
|
||||||
|
- 노트 스폰 타이밍과 이동 속도 보정에 필요한 정보를 전달하도록 확장했다.
|
||||||
|
|
||||||
|
#### VR UI/입력
|
||||||
|
|
||||||
|
- `Assets/Script/VRPointerController.cs`, `VRPointerSetup.cs`
|
||||||
|
- VR 컨트롤러 레이 버튼 클릭 안정성을 보강했다.
|
||||||
|
- 비활성 Canvas의 버튼이 레이캐스트 후보에 남아 클릭을 빼앗는 문제를 필터링했다.
|
||||||
|
- SongSelect 스크롤 속도를 올리고, 검지 트리거를 누른 상태로 위/아래 드래그해 스크롤할 수 있게 했다.
|
||||||
|
- 메뉴/게임 씬 전환 후에도 포인터가 다시 주입되도록 유지했다.
|
||||||
|
- `Assets/VRBeatsKit/Prefabs/PlayerSetup/MenuPlayer.prefab`, `SaberStylePlayer.prefab`, `VR_InteractorController.cs`
|
||||||
|
- XR Interaction Toolkit의 `XRRayInteractor`/`XRInteractorLineVisual`과 커스텀 포인터가 겹쳐 버튼 입력이 꼬이는 문제를 막기 위해 XRI 레이 시각화를 비활성화했다.
|
||||||
|
- `Missing ILineRenderable / Ray Interactor component` 오류가 반복되지 않도록 런타임 enable 흐름도 커스텀 포인터 기준으로 정리했다.
|
||||||
|
|
||||||
|
#### 비주얼/배경/세이버
|
||||||
|
|
||||||
|
- `Assets/Script/Game360VideoBackground.cs`, `Assets/Scenes/Game.unity`
|
||||||
|
- 게임 씬 배경을 360도 영상처럼 둘러싼 내부 구체/스카이박스형 배경으로 재생하도록 추가했다.
|
||||||
|
- 영상은 Unity 호환 H.264 baseline/CFR MP4로 변환해 쓰는 것을 권장한다. 원본에 timestamp warning이 있으면 `ffmpeg`로 재인코딩한다.
|
||||||
|
- `Assets/VRBeatsKit/Scripts/Core/SaberTrailEffect.cs`
|
||||||
|
- 기존 검끝 한 점 `TrailRenderer` 방식 대신, 세이버 `Start-End` 검신 전체를 샘플링하는 월드 스페이스 리본 메쉬 잔상으로 교체했다.
|
||||||
|
- 검끝에서만 빙글 도는 헬리콥터 같은 잔상 대신, 검신이 휘둘린 면이 짧게 남는 형태다.
|
||||||
|
- `Assets/VRBeatsKit/Scripts/Core/SliceTrailEffect.cs`
|
||||||
|
- 큐브가 잘릴 때 절단면/파편 쪽에서 짧은 트레일이 남도록 별도 효과를 추가했다.
|
||||||
|
- `Assets/VRBeatsKit/Settings/Settings.asset`, `DefaultVolumeProfile.asset`
|
||||||
|
- 큐브 네온/블룸 강도를 낮춰 과한 발광을 줄였다.
|
||||||
|
- `Assets/VRBeatsKit/Prefabs/VR_Saber/2/RightFuturisticSword.prefab`
|
||||||
|
- 두 번째 세이버도 너무 수직으로 서지 않도록 프리팹 회전을 보정했다.
|
||||||
|
|
||||||
|
#### 싱크 보정/개발 도구
|
||||||
|
|
||||||
|
- `Assets/Script/GlobalSyncSettings.cs`, `SyncCalibrationOverlay.cs`, `MenuSyncButtonInjector.cs`
|
||||||
|
- 메뉴의 Create Song 버튼 아래에 Sync 버튼을 주입하고, 별도 싱크 보정 화면을 열 수 있게 했다.
|
||||||
|
- 주기적인 틱 사운드/시각 기준을 보고 오른쪽 컨트롤러 A 버튼 또는 키보드로 입력 지연을 기록하는 구조다.
|
||||||
|
- TextMeshPro 한글 깨짐을 줄이기 위해 `TMP Settings.asset`에 NanumGothic fallback을 추가했다.
|
||||||
|
- `Assets/Editor/UnityCodexBridgeServer.cs`, `tools/unity-mcp-server/`, `docs/unity_mcp_bridge.md`
|
||||||
|
- Codex가 Unity 에디터의 health/log/scene/capture/play state를 조회할 수 있는 로컬 브리지와 MCP 서버를 추가했다.
|
||||||
|
- 현재 MCP 연결은 세션/포트 상태에 따라 재시작이 필요할 수 있다.
|
||||||
|
|
||||||
|
#### NAS/다운로드/생성
|
||||||
|
|
||||||
|
- `Assets/Script/NasPublisher.cs`
|
||||||
|
- `nas_config.json` 또는 로컬 `env` 기반으로 NAS 계정/비밀번호를 읽도록 정리했다.
|
||||||
|
- DSM 응답 파싱 실패/비밀번호 누락/URL 공백 문제를 더 명확히 로그로 남기도록 보강했다.
|
||||||
|
- `Assets/Script/DownloadManager.cs`, `SongLibrary.cs`
|
||||||
|
- 곡이 실제로 다운로드되는 경로를 로그와 상태로 확인할 수 있게 했다.
|
||||||
|
- 곡 삭제 시 mp3/map json/다운로드 상태가 같이 지워지도록 정리해 캐시가 쌓이기만 하는 문제를 줄였다.
|
||||||
|
- `Assets/Script/BeatSageConverter.cs`, `BeatSageUploader.cs`
|
||||||
|
- Beat Sage 변환 결과와 노트 수 로그를 확인하기 쉽게 유지했다.
|
||||||
|
|
||||||
|
#### 로컬 전용/커밋 제외
|
||||||
|
|
||||||
|
- `env`, `cookies.txt`, `Assets/_Recovery/`는 로컬 전용이라 커밋하지 않는다.
|
||||||
|
- `tools/unity-mcp-server/node_modules/`도 커밋 제외다.
|
||||||
|
|
||||||
|
### 2026-05-26 이전 반영된 변경
|
||||||
|
|
||||||
- `Assets/Script/SongController.cs`
|
- `Assets/Script/SongController.cs`
|
||||||
- Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다.
|
- Beat Saber 4라인 좌표 매핑을 넓혀 인접 큐브가 가로로 겹치는 문제를 줄였다.
|
||||||
@@ -94,13 +163,13 @@ SongCreator.unity
|
|||||||
|
|
||||||
### 현재 주의사항
|
### 현재 주의사항
|
||||||
|
|
||||||
1. `Assets/StreamingAssets/nas_config.json`은 현재 저장소에 없다. NAS 업로드를 테스트하려면 로컬에 직접 만들어야 하며, 절대 커밋하지 않는다.
|
1. `Assets/StreamingAssets/nas_config.json`과 루트 `env`는 로컬 전용이다. NAS 업로드 테스트 전 계정/비밀번호를 직접 넣되 절대 커밋하지 않는다.
|
||||||
2. `SongCreator.unity`의 직렬화된 `nasBaseUrl` 값에 끝 공백이 들어가 있다: `http://whdwo798.synology.me:5000 `. 런타임에서 `nas_config.json`으로 덮어쓰지 않으면 로그인 URL 문제가 날 수 있다.
|
2. Unity 콘솔의 `Unable to start Oculus XR Plugin`은 헤드셋/오큘러스 런타임 상태 문제일 수 있다. PC 에디터 단독 실행에서는 경고가 날 수 있다.
|
||||||
3. `SongCreatorManager`는 난이도 토글 필드를 갖고 있지만 현재 로직은 4개 난이도(`normal`, `hard`, `expert`, `expertplus`)를 항상 전부 생성한다.
|
3. `The referenced script (Unknown) on this Behaviour is missing!` 경고가 남는 씬/프리팹은 추가 확인 대상이다. 게임 진행을 막는 직접 원인은 아니지만, 인스펙터에서 Missing Script를 정리하는 것이 좋다.
|
||||||
4. `manualEditorButton`은 씬에서 미연결이고 코드에서도 사용하지 않는다.
|
4. 영상 배경은 Unity 호환 MP4가 안전하다. H.264 timestamp warning이 뜨는 원본은 baseline profile, CFR, yuv420p로 재인코딩한다.
|
||||||
5. `Assets/img/360.mp4`는 약 192MB다. 현재 일반 Git으로 푸시되어 있으므로, 원격 정책이 바뀌면 Git LFS 전환을 검토해야 한다.
|
5. `Assets/img` 아래 영상 파일은 크기가 커질 수 있다. 원격 저장소 정책에 걸리면 Git LFS 전환을 검토한다.
|
||||||
6. 큐브 간격은 수치상 겹침을 피하도록 넓혔지만, 실제 Quest 착용 테스트에서 손 위치/판정 거리/시야 피로도를 확인해야 한다.
|
6. 싱크 보정 화면은 UI/입력 구조까지 들어갔지만, 기기별 오디오 지연 값은 Quest 실기에서 측정해야 한다.
|
||||||
7. SongCreator에서 생성 직후 첫 재생이 곡에 따라 늦거나 싱크가 흔들리는 체감이 있었다. 게임 씬 오디오 기준은 `AudioSettings.dspTime`으로 개선했지만, 생성/다운로드/첫 로드 전체 파이프라인은 추가 로그 검증이 필요하다.
|
7. 세이버 잔상, 큐브 이동 속도, 블룸 강도는 체감 튜닝 항목이다. 현재 값은 빌드/컴파일 기준으로 안전하지만, VR 착용 테스트 후 수치를 조정하는 것이 좋다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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