feat: polish VR gameplay and sync tools

This commit is contained in:
jongjae0305
2026-05-28 19:01:20 +09:00
parent ee34d79a66
commit 03105a4f85
50 changed files with 4986 additions and 328 deletions
+910
View File
@@ -0,0 +1,910 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace VRBeats.EditorTools
{
[InitializeOnLoad]
internal static class UnityCodexBridgeServer
{
private const int Port = 19744;
private const string AutoStartPrefKey = "VRBeats.CodexBridge.AutoStart";
private const int MaxLogs = 250;
private static readonly ConcurrentQueue<BridgeJob> Jobs = new ConcurrentQueue<BridgeJob>();
private static readonly List<LogEntry> Logs = new List<LogEntry>();
private static readonly object LogLock = new object();
private static TcpListener _listener;
private static Thread _serverThread;
private static bool _running;
private static int _logIndex;
static UnityCodexBridgeServer()
{
if (IsBackgroundEditorProcess())
return;
Application.logMessageReceived -= OnLogMessageReceived;
Application.logMessageReceived += OnLogMessageReceived;
EditorApplication.update -= ProcessJobs;
EditorApplication.update += ProcessJobs;
EditorApplication.quitting -= StopServer;
EditorApplication.quitting += StopServer;
AssemblyReloadEvents.beforeAssemblyReload -= StopServer;
AssemblyReloadEvents.beforeAssemblyReload += StopServer;
if (EditorPrefs.GetBool(AutoStartPrefKey, true))
StartServer();
}
[MenuItem("Tools/Codex Bridge/Start Server")]
private static void StartServerMenu()
{
StartServer();
}
[MenuItem("Tools/Codex Bridge/Stop Server")]
private static void StopServerMenu()
{
StopServer();
}
[MenuItem("Tools/Codex Bridge/Auto Start")]
private static void ToggleAutoStart()
{
bool enabled = !EditorPrefs.GetBool(AutoStartPrefKey, true);
EditorPrefs.SetBool(AutoStartPrefKey, enabled);
if (enabled)
StartServer();
}
[MenuItem("Tools/Codex Bridge/Auto Start", true)]
private static bool ValidateToggleAutoStart()
{
Menu.SetChecked("Tools/Codex Bridge/Auto Start", EditorPrefs.GetBool(AutoStartPrefKey, true));
return true;
}
[MenuItem("Tools/Codex Bridge/Capture Game View")]
private static void CaptureGameViewMenu()
{
BridgeResponse response = CaptureGameView(new Dictionary<string, string>());
Debug.Log("[CodexBridge] Capture result: " + response.Body);
}
[MenuItem("Tools/Codex Bridge/Open Sync Calibration")]
private static void OpenSyncCalibrationMenu()
{
SyncCalibrationOverlay.Open();
}
private static void StartServer()
{
if (IsBackgroundEditorProcess())
return;
if (_running)
return;
try
{
_listener = new TcpListener(IPAddress.Loopback, Port);
_listener.Server.ExclusiveAddressUse = true;
_listener.Start();
_running = true;
_serverThread = new Thread(ServerLoop)
{
IsBackground = true,
Name = "UnityCodexBridgeServer"
};
_serverThread.Start();
Debug.Log("[CodexBridge] Listening on http://127.0.0.1:" + Port);
}
catch (Exception ex)
{
_running = false;
try
{
if (_listener != null)
_listener.Stop();
}
catch
{
// Ignore cleanup failures after a failed bind.
}
finally
{
_listener = null;
}
Debug.LogWarning("[CodexBridge] Failed to start: " + ex.Message);
}
}
private static void StopServer()
{
_running = false;
try
{
if (_listener != null)
_listener.Stop();
}
catch
{
// Ignore shutdown races.
}
_listener = null;
if (_serverThread != null && _serverThread.IsAlive)
_serverThread.Join(200);
_serverThread = null;
}
private static bool IsBackgroundEditorProcess()
{
string commandLine = Environment.CommandLine;
return Application.isBatchMode ||
commandLine.IndexOf("AssetImportWorker", StringComparison.OrdinalIgnoreCase) >= 0 ||
commandLine.IndexOf("-batchMode", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static void ServerLoop()
{
while (_running)
{
try
{
TcpClient client = _listener.AcceptTcpClient();
ThreadPool.QueueUserWorkItem(_ => HandleClient(client));
}
catch
{
if (_running)
Thread.Sleep(100);
}
}
}
private static void HandleClient(TcpClient client)
{
using (client)
{
try
{
client.ReceiveTimeout = 5000;
client.SendTimeout = 5000;
BridgeRequest request = ReadRequest(client.GetStream());
BridgeResponse response;
if (request == null)
{
response = BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"invalid_request\"}");
}
else if (request.Method == "OPTIONS")
{
response = BridgeResponse.Json(204, string.Empty);
}
else
{
BridgeJob job = new BridgeJob(request);
Jobs.Enqueue(job);
if (!job.Done.Wait(TimeSpan.FromSeconds(10)))
response = BridgeResponse.Json(504, "{\"ok\":false,\"error\":\"unity_main_thread_timeout\"}");
else
response = job.Response;
}
WriteResponse(client.GetStream(), response);
}
catch (Exception ex)
{
try
{
WriteResponse(client.GetStream(),
BridgeResponse.Json(500, "{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}"));
}
catch
{
// Client has already gone away.
}
}
}
}
private static BridgeRequest ReadRequest(Stream stream)
{
StreamReader reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true);
string requestLine = reader.ReadLine();
if (string.IsNullOrEmpty(requestLine))
return null;
string[] requestParts = requestLine.Split(' ');
if (requestParts.Length < 2)
return null;
int contentLength = 0;
string line;
while (!string.IsNullOrEmpty(line = reader.ReadLine()))
{
int separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
continue;
string headerName = line.Substring(0, separatorIndex).Trim();
string headerValue = line.Substring(separatorIndex + 1).Trim();
if (string.Equals(headerName, "Content-Length", StringComparison.OrdinalIgnoreCase))
int.TryParse(headerValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out contentLength);
}
string body = string.Empty;
if (contentLength > 0)
{
char[] buffer = new char[contentLength];
int read = reader.ReadBlock(buffer, 0, contentLength);
body = new string(buffer, 0, read);
}
Uri uri = new Uri("http://127.0.0.1" + requestParts[1]);
return new BridgeRequest(requestParts[0].ToUpperInvariant(), uri.AbsolutePath, ParseQuery(uri.Query), body);
}
private static Dictionary<string, string> ParseQuery(string query)
{
Dictionary<string, string> values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(query))
return values;
string trimmed = query[0] == '?' ? query.Substring(1) : query;
string[] pairs = trimmed.Split('&');
foreach (string pair in pairs)
{
if (string.IsNullOrEmpty(pair))
continue;
int separatorIndex = pair.IndexOf('=');
string key = separatorIndex >= 0 ? pair.Substring(0, separatorIndex) : pair;
string value = separatorIndex >= 0 ? pair.Substring(separatorIndex + 1) : string.Empty;
values[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(value.Replace("+", " "));
}
return values;
}
private static void WriteResponse(Stream stream, BridgeResponse response)
{
if (response == null)
response = BridgeResponse.Json(500, "{\"ok\":false,\"error\":\"null_response\"}");
byte[] body = Encoding.UTF8.GetBytes(response.Body ?? string.Empty);
string headers =
"HTTP/1.1 " + response.StatusCode + " " + StatusText(response.StatusCode) + "\r\n" +
"Content-Type: " + response.ContentType + "; charset=utf-8\r\n" +
"Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n" +
"Access-Control-Allow-Origin: http://localhost\r\n" +
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" +
"Access-Control-Allow-Headers: Content-Type\r\n" +
"Connection: close\r\n\r\n";
byte[] headerBytes = Encoding.UTF8.GetBytes(headers);
stream.Write(headerBytes, 0, headerBytes.Length);
if (body.Length > 0)
stream.Write(body, 0, body.Length);
}
private static string StatusText(int statusCode)
{
switch (statusCode)
{
case 200: return "OK";
case 204: return "No Content";
case 400: return "Bad Request";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 500: return "Internal Server Error";
case 504: return "Gateway Timeout";
default: return "OK";
}
}
private static void ProcessJobs()
{
while (Jobs.TryDequeue(out BridgeJob job))
{
try
{
job.Response = Execute(job.Request);
}
catch (Exception ex)
{
job.Response = BridgeResponse.Json(500,
"{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}");
}
finally
{
job.Done.Set();
}
}
}
private static BridgeResponse Execute(BridgeRequest request)
{
switch (request.Path)
{
case "/health":
case "/state":
return GetHealth();
case "/capture":
return CaptureGameView(request.Query);
case "/logs":
return GetLogs(request.Query);
case "/scene/roots":
return GetSceneRoots();
case "/scene/objects":
return GetSceneObjects(request.Query);
case "/object":
return GetObjectDetails(request.Query);
case "/play":
return SetPlayState(true, false);
case "/pause":
return SetPlayState(true, true);
case "/stop":
return SetPlayState(false, false);
case "/sync/open":
SyncCalibrationOverlay.Open();
return BridgeResponse.Json(200, "{\"ok\":true,\"opened\":\"sync_calibration\"}");
case "/transform":
if (request.Method != "POST")
return BridgeResponse.Json(405, "{\"ok\":false,\"error\":\"method_not_allowed\"}");
return SetTransform(request.Body);
default:
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"not_found\"}");
}
}
private static BridgeResponse GetHealth()
{
Scene scene = SceneManager.GetActiveScene();
string body =
"{\"ok\":true" +
",\"bridge\":\"unity-codex-bridge\"" +
",\"port\":" + Port.ToString(CultureInfo.InvariantCulture) +
",\"unityVersion\":" + JsonString(Application.unityVersion) +
",\"projectPath\":" + JsonString(Directory.GetCurrentDirectory()) +
",\"scene\":" + JsonString(scene.IsValid() ? scene.name : string.Empty) +
",\"isPlaying\":" + Bool(EditorApplication.isPlaying) +
",\"isPaused\":" + Bool(EditorApplication.isPaused) +
"}";
return BridgeResponse.Json(200, body);
}
private static BridgeResponse CaptureGameView(Dictionary<string, string> query)
{
int width = GetInt(query, "width", 1280, 320, 4096);
int height = GetInt(query, "height", 720, 180, 4096);
string relativePath = "Captures/latest.png";
string absolutePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
Directory.CreateDirectory(Path.GetDirectoryName(absolutePath));
Camera camera = ResolveCaptureCamera();
string cameraName = camera != null ? camera.name : string.Empty;
bool usedCameraRender = false;
if (camera != null)
{
RenderTexture previousTarget = camera.targetTexture;
RenderTexture previousActive = RenderTexture.active;
RenderTexture renderTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32);
Texture2D texture = null;
try
{
camera.targetTexture = renderTexture;
RenderTexture.active = renderTexture;
camera.Render();
texture = new Texture2D(width, height, TextureFormat.RGB24, false);
texture.ReadPixels(new Rect(0, 0, width, height), 0, 0);
texture.Apply();
File.WriteAllBytes(absolutePath, texture.EncodeToPNG());
usedCameraRender = true;
}
finally
{
camera.targetTexture = previousTarget;
RenderTexture.active = previousActive;
RenderTexture.ReleaseTemporary(renderTexture);
if (texture != null)
UnityEngine.Object.DestroyImmediate(texture);
}
}
if (!usedCameraRender)
ScreenCapture.CaptureScreenshot(relativePath);
string body =
"{\"ok\":true" +
",\"path\":" + JsonString(absolutePath) +
",\"relativePath\":" + JsonString(relativePath) +
",\"width\":" + width.ToString(CultureInfo.InvariantCulture) +
",\"height\":" + height.ToString(CultureInfo.InvariantCulture) +
",\"camera\":" + JsonString(cameraName) +
",\"mode\":" + JsonString(usedCameraRender ? "camera_render" : "screen_capture") +
"}";
return BridgeResponse.Json(200, body);
}
private static Camera ResolveCaptureCamera()
{
Camera main = Camera.main;
if (main != null && main.isActiveAndEnabled)
return main;
Camera[] cameras = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
foreach (Camera camera in cameras)
{
if (camera != null && camera.isActiveAndEnabled)
return camera;
}
return main != null ? main : (cameras.Length > 0 ? cameras[0] : null);
}
private static BridgeResponse GetLogs(Dictionary<string, string> query)
{
int count = GetInt(query, "count", 80, 1, MaxLogs);
List<LogEntry> snapshot;
lock (LogLock)
{
int start = Mathf.Max(0, Logs.Count - count);
snapshot = Logs.GetRange(start, Logs.Count - start);
}
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"logs\":[");
for (int i = 0; i < snapshot.Count; i++)
{
if (i > 0)
sb.Append(',');
LogEntry entry = snapshot[i];
sb.Append('{');
sb.Append("\"index\":").Append(entry.Index.ToString(CultureInfo.InvariantCulture)).Append(',');
sb.Append("\"time\":").Append(JsonString(entry.Time)).Append(',');
sb.Append("\"type\":").Append(JsonString(entry.Type)).Append(',');
sb.Append("\"message\":").Append(JsonString(entry.Message)).Append(',');
sb.Append("\"stackTrace\":").Append(JsonString(entry.StackTrace));
sb.Append('}');
}
sb.Append("]}");
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetSceneRoots()
{
Scene scene = SceneManager.GetActiveScene();
GameObject[] roots = scene.IsValid() ? scene.GetRootGameObjects() : Array.Empty<GameObject>();
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"scene\":").Append(JsonString(scene.IsValid() ? scene.name : string.Empty));
sb.Append(",\"roots\":[");
for (int i = 0; i < roots.Length; i++)
{
if (i > 0)
sb.Append(',');
AppendGameObjectSummary(sb, roots[i]);
}
sb.Append("]}");
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetSceneObjects(Dictionary<string, string> query)
{
string filter = GetString(query, "query", string.Empty);
int limit = GetInt(query, "limit", 120, 1, 500);
string lowerFilter = filter.ToLowerInvariant();
Transform[] transforms = UnityEngine.Object.FindObjectsByType<Transform>(FindObjectsInactive.Include, FindObjectsSortMode.None);
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"objects\":[");
int written = 0;
foreach (Transform transform in transforms)
{
if (transform == null || transform.gameObject == null)
continue;
if (EditorUtility.IsPersistent(transform.gameObject))
continue;
if (!transform.gameObject.scene.IsValid())
continue;
string path = GetHierarchyPath(transform.gameObject);
if (!string.IsNullOrEmpty(lowerFilter) &&
path.ToLowerInvariant().IndexOf(lowerFilter, StringComparison.Ordinal) < 0)
continue;
if (written > 0)
sb.Append(',');
AppendGameObjectSummary(sb, transform.gameObject);
written++;
if (written >= limit)
break;
}
sb.Append("],\"count\":").Append(written.ToString(CultureInfo.InvariantCulture)).Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse GetObjectDetails(Dictionary<string, string> query)
{
string path = GetString(query, "path", string.Empty);
GameObject go = FindGameObjectByPath(path);
if (go == null)
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}");
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"object\":");
AppendGameObjectDetails(sb, go);
sb.Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static BridgeResponse SetPlayState(bool play, bool pause)
{
EditorApplication.isPlaying = play;
EditorApplication.isPaused = pause;
return GetHealth();
}
private static BridgeResponse SetTransform(string body)
{
string path = ExtractString(body, "path");
if (string.IsNullOrEmpty(path))
return BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"missing_path\"}");
GameObject go = FindGameObjectByPath(path);
if (go == null)
return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}");
Undo.RecordObject(go.transform, "Codex Bridge Set Transform");
if (TryExtractVector3(body, "position", out Vector3 position))
go.transform.position = position;
if (TryExtractVector3(body, "localPosition", out Vector3 localPosition))
go.transform.localPosition = localPosition;
if (TryExtractVector3(body, "rotationEuler", out Vector3 rotationEuler))
go.transform.eulerAngles = rotationEuler;
if (TryExtractVector3(body, "localRotationEuler", out Vector3 localRotationEuler))
go.transform.localEulerAngles = localRotationEuler;
if (TryExtractVector3(body, "scale", out Vector3 scale))
go.transform.localScale = scale;
EditorUtility.SetDirty(go.transform);
StringBuilder sb = new StringBuilder();
sb.Append("{\"ok\":true,\"object\":");
AppendGameObjectDetails(sb, go);
sb.Append('}');
return BridgeResponse.Json(200, sb.ToString());
}
private static void AppendGameObjectSummary(StringBuilder sb, GameObject go)
{
sb.Append('{');
sb.Append("\"name\":").Append(JsonString(go.name)).Append(',');
sb.Append("\"path\":").Append(JsonString(GetHierarchyPath(go))).Append(',');
sb.Append("\"activeSelf\":").Append(Bool(go.activeSelf)).Append(',');
sb.Append("\"activeInHierarchy\":").Append(Bool(go.activeInHierarchy)).Append(',');
sb.Append("\"scene\":").Append(JsonString(go.scene.IsValid() ? go.scene.name : string.Empty)).Append(',');
sb.Append("\"position\":").Append(VectorJson(go.transform.position)).Append(',');
sb.Append("\"rotationEuler\":").Append(VectorJson(go.transform.eulerAngles)).Append(',');
sb.Append("\"scale\":").Append(VectorJson(go.transform.localScale));
sb.Append('}');
}
private static void AppendGameObjectDetails(StringBuilder sb, GameObject go)
{
AppendGameObjectSummary(sb, go);
sb.Length -= 1;
Component[] components = go.GetComponents<Component>();
sb.Append(",\"components\":[");
for (int i = 0; i < components.Length; i++)
{
if (i > 0)
sb.Append(',');
Component component = components[i];
sb.Append(JsonString(component != null ? component.GetType().FullName : "Missing Script"));
}
sb.Append("]}");
}
private static GameObject FindGameObjectByPath(string path)
{
if (string.IsNullOrEmpty(path))
return null;
string[] parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return null;
Scene scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
return null;
foreach (GameObject root in scene.GetRootGameObjects())
{
if (root.name != parts[0])
continue;
Transform current = root.transform;
for (int i = 1; i < parts.Length && current != null; i++)
{
current = FindChildByName(current, parts[i]);
}
if (current != null)
return current.gameObject;
}
return null;
}
private static Transform FindChildByName(Transform parent, string childName)
{
for (int i = 0; i < parent.childCount; i++)
{
Transform child = parent.GetChild(i);
if (child.name == childName)
return child;
}
return null;
}
private static string GetHierarchyPath(GameObject go)
{
Stack<string> parts = new Stack<string>();
Transform current = go.transform;
while (current != null)
{
parts.Push(current.name);
current = current.parent;
}
return string.Join("/", parts.ToArray());
}
private static void OnLogMessageReceived(string condition, string stackTrace, LogType type)
{
lock (LogLock)
{
Logs.Add(new LogEntry
{
Index = ++_logIndex,
Time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
Type = type.ToString(),
Message = condition ?? string.Empty,
StackTrace = stackTrace ?? string.Empty
});
if (Logs.Count > MaxLogs)
Logs.RemoveAt(0);
}
}
private static int GetInt(Dictionary<string, string> query, string key, int fallback, int min, int max)
{
if (!query.TryGetValue(key, out string value) ||
!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
return fallback;
return Mathf.Clamp(parsed, min, max);
}
private static string GetString(Dictionary<string, string> query, string key, string fallback)
{
return query.TryGetValue(key, out string value) ? value : fallback;
}
private static string ExtractString(string json, string key)
{
if (string.IsNullOrEmpty(json))
return string.Empty;
Match match = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"",
RegexOptions.CultureInvariant);
return match.Success ? Regex.Unescape(match.Groups[1].Value) : string.Empty;
}
private static bool TryExtractVector3(string json, string key, out Vector3 value)
{
value = Vector3.zero;
if (string.IsNullOrEmpty(json))
return false;
Match objectMatch = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*\\{(?<body>.*?)\\}",
RegexOptions.Singleline | RegexOptions.CultureInvariant);
if (!objectMatch.Success)
return false;
string objectBody = objectMatch.Groups["body"].Value;
if (!TryExtractFloat(objectBody, "x", out float x) ||
!TryExtractFloat(objectBody, "y", out float y) ||
!TryExtractFloat(objectBody, "z", out float z))
return false;
value = new Vector3(x, y, z);
return true;
}
private static bool TryExtractFloat(string json, string key, out float value)
{
value = 0f;
Match match = Regex.Match(json,
"\"" + Regex.Escape(key) + "\"\\s*:\\s*(?<num>-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)",
RegexOptions.CultureInvariant);
return match.Success &&
float.TryParse(match.Groups["num"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
private static string VectorJson(Vector3 value)
{
return "{\"x\":" + FloatJson(value.x) +
",\"y\":" + FloatJson(value.y) +
",\"z\":" + FloatJson(value.z) + "}";
}
private static string FloatJson(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value))
return "0";
return value.ToString("0.#####", CultureInfo.InvariantCulture);
}
private static string Bool(bool value)
{
return value ? "true" : "false";
}
private static string JsonString(string value)
{
if (value == null)
return "null";
StringBuilder sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (char c in value)
{
switch (c)
{
case '\\':
sb.Append("\\\\");
break;
case '"':
sb.Append("\\\"");
break;
case '\n':
sb.Append("\\n");
break;
case '\r':
sb.Append("\\r");
break;
case '\t':
sb.Append("\\t");
break;
default:
if (c < 32)
sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
else
sb.Append(c);
break;
}
}
sb.Append('"');
return sb.ToString();
}
private sealed class BridgeRequest
{
public readonly string Method;
public readonly string Path;
public readonly Dictionary<string, string> Query;
public readonly string Body;
public BridgeRequest(string method, string path, Dictionary<string, string> query, string body)
{
Method = method;
Path = path;
Query = query;
Body = body ?? string.Empty;
}
}
private sealed class BridgeResponse
{
public readonly int StatusCode;
public readonly string ContentType;
public readonly string Body;
private BridgeResponse(int statusCode, string contentType, string body)
{
StatusCode = statusCode;
ContentType = contentType;
Body = body;
}
public static BridgeResponse Json(int statusCode, string body)
{
return new BridgeResponse(statusCode, "application/json", body);
}
}
private sealed class BridgeJob
{
public readonly BridgeRequest Request;
public readonly ManualResetEventSlim Done = new ManualResetEventSlim(false);
public BridgeResponse Response;
public BridgeJob(BridgeRequest request)
{
Request = request;
}
}
private sealed class LogEntry
{
public int Index;
public string Time;
public string Type;
public string Message;
public string StackTrace;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3aaf23c3d6f42b5b1f68100b9b0f682
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+88 -22
View File
@@ -587,13 +587,18 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: colorSide
@@ -627,12 +632,12 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.9239
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: 0.3827
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -647,7 +652,7 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 45
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -1263,7 +1268,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: -7.5, y: -1.7}
m_AnchoredPosition: {x: 5.8, y: 2.4}
m_SizeDelta: {x: 847.5, y: 1141.086}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &454873730
@@ -1284,6 +1289,15 @@ MonoBehaviour:
scoreFollowTime: 1
canvasGroup: {fileID: 454873732}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
comboLabel: {fileID: 0}
accuracyLabel: {fileID: 0}
judgementLabel: {fileID: 0}
createMissingHudLabels: 1
applyHudPlacement: 1
hudAnchoredPosition: {x: 5.8, y: 2.4}
perfectWindow: 0.08
greatWindow: 0.15
goodWindow: 0.25
--- !u!114 &454873731
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -2380,7 +2394,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_destroyOnLoad: 1
currentSDK: 0
currentSDK: 3
gestureConfig:
minAcelerationThreshold: 15
maxAcelerationThreshold: 40
@@ -2652,8 +2666,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: -1.4000015, y: 2.5099983}
m_SizeDelta: {x: 20.150002, y: 6.41}
m_AnchoredPosition: {x: 0, y: -0.45}
m_SizeDelta: {x: 18.8, y: 13.2}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1209841520
GameObject:
@@ -2692,7 +2706,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -16.119999, y: -15.380001}
m_AnchoredPosition: {x: -17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1209841522
@@ -2825,7 +2839,6 @@ MonoBehaviour:
playZone: {fileID: 778115775}
player: {fileID: 408071456}
settings: {fileID: 11400000, guid: 77da81612feecbe429ec290359d3547e, type: 2}
onGameOver: {fileID: 11400000, guid: 620f7aac602ad78418fb6c1ca0a6670a, type: 2}
--- !u!4 &1215359035
Transform:
m_ObjectHideFlags: 0
@@ -3790,8 +3803,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 7.1
m_fontSizeBase: 7.1
m_fontSize: 4.4
m_fontSizeBase: 4.4
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -3803,7 +3816,7 @@ MonoBehaviour:
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacing: -18
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
@@ -4376,6 +4389,7 @@ GameObject:
m_Component:
- component: {fileID: 1958381893}
- component: {fileID: 1958381892}
- component: {fileID: 1958381894}
m_Layer: 0
m_Name: SongController
m_TagString: Untagged
@@ -4414,6 +4428,23 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1958381894
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1958381891}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3e381cd99de84f67b9f83c19a032dc24, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Game360VideoBackground
videoClip: {fileID: 32900000, guid: f47e26c36f77476ba803b5158d1b30da, type: 3}
renderTextureSize: 2048
muteVideoAudio: 1
skyboxRotationDegrees: 90
skyboxExposure: 1
--- !u!4 &2043306906 stripped
Transform:
m_CorrespondingSourceObject: {fileID: 1002067974212273307, guid: e7173b1ee3369204eb181b376ede2a3e,
@@ -4457,7 +4488,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 15.980001, y: -15.380001}
m_AnchoredPosition: {x: 17.400002, y: -15.380001}
m_SizeDelta: {x: 357, y: 157}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2094521063
@@ -4857,6 +4888,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -4865,13 +4901,23 @@ PrefabInstance:
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.handSelected
value: 1
value: 0
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 934260912312062542, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3968956848465607219, guid: f555cbb0b089fb64ca7c7a5b07f11520,
type: 3}
propertyPath: m_RootOrder
@@ -5157,6 +5203,11 @@ PrefabInstance:
propertyPath: startOnRightController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutBasic
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInput
@@ -5165,13 +5216,23 @@ PrefabInstance:
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.selectedMenu
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: shareHandInteractionSettings
value: 1
objectReference: {fileID: 0}
- target: {fileID: 3894408489718918374, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: editorPart.foldoutInteraction
value: 1
objectReference: {fileID: 0}
- target: {fileID: 8620810952101844687, guid: e7173b1ee3369204eb181b376ede2a3e,
type: 3}
propertyPath: m_Name
@@ -5277,7 +5338,7 @@ PrefabInstance:
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: editorPart.handSelected
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
@@ -5289,6 +5350,11 @@ PrefabInstance:
propertyPath: startOnRightcController
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6994737657188836379, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: handSettings.rotationOffset.x
value: 45
objectReference: {fileID: 0}
- target: {fileID: 7214299079173282187, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: controller
@@ -5317,27 +5383,27 @@ PrefabInstance:
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.w
value: 0.99958926
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.x
value: -0
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.y
value: -0.028659718
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalRotation.z
value: -0
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
value: 90
objectReference: {fileID: 0}
- target: {fileID: 7481659171502770090, guid: d614df01a29ad3e45bb831298dfbad2c,
type: 3}
+1 -1
View File
@@ -8107,7 +8107,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2379e0d70040c994089638264e6e9934, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NasPublisher
nasBaseUrl: 'http://whdwo798.synology.me:5000 '
nasBaseUrl: http://whdwo798.synology.me:5000
nasAccount: beatSaber_app
nasRootPath: /web/beatsaber
staticBaseUrl: http://whdwo798.synology.me/beatsaber
+4 -1
View File
@@ -36,6 +36,8 @@ public class BeatSageNote
public static class BeatSageConverter
{
private static readonly bool LogConversions = false;
public static List<NoteData> Convert(string rawJson, float bpm)
{
var result = new List<NoteData>();
@@ -62,7 +64,8 @@ public static class BeatSageConverter
});
}
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
if (LogConversions)
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
return result;
}
+53 -2
View File
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
string legacyDir = LegacySongDir(songId);
if (Directory.Exists(legacyDir))
Directory.Delete(legacyDir, recursive: true);
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
string songDir = SongDir(song.id);
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
Directory.Delete(songDir);
}
public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId));
{
TryMigrateLegacySong(songId);
return File.Exists(AudioPath(songId));
}
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
return path != null && File.Exists(path);
}
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError)
{
TryMigrateLegacySong(song.id);
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
private static string LegacySongDir(string songId)
=> Path.Combine(LegacyCacheRoot, songId);
private static void TryMigrateLegacySong(string songId)
{
string sourceDir = LegacySongDir(songId);
string targetDir = SongDir(songId);
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
return;
CopyDirectory(sourceDir, targetDir);
Directory.Delete(sourceDir, recursive: true);
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
}
private static void CopyDirectory(string sourceDir, string targetDir)
{
Directory.CreateDirectory(targetDir);
foreach (string file in Directory.GetFiles(sourceDir))
{
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
File.Copy(file, targetFile, overwrite: true);
}
foreach (string dir in Directory.GetDirectories(sourceDir))
{
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
CopyDirectory(dir, targetSubDir);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using UnityEngine;
using UnityEngine.Video;
public class Game360VideoBackground : MonoBehaviour
{
[SerializeField] private VideoClip videoClip;
[SerializeField] private int renderTextureSize = 2048;
[SerializeField] private bool muteVideoAudio = true;
[SerializeField, Range(0f, 360f)] private float skyboxRotationDegrees = 0f;
[SerializeField, Range(0f, 8f)] private float skyboxExposure = 1f;
private GameObject videoPlayerObject;
private Material skyboxMaterial;
private Material previousSkybox;
private RenderTexture renderTexture;
private VideoPlayer videoPlayer;
private void Awake()
{
if (videoClip == null)
{
Debug.LogWarning("[Game360VideoBackground] videoClip is not assigned.");
return;
}
CreateSkyboxMaterial();
CreateVideoPlayer();
}
private void OnDestroy()
{
if (videoPlayer != null)
{
videoPlayer.prepareCompleted -= OnVideoPrepared;
videoPlayer.errorReceived -= OnVideoError;
}
if (renderTexture != null)
{
renderTexture.Release();
Destroy(renderTexture);
}
RenderSettings.skybox = previousSkybox;
DynamicGI.UpdateEnvironment();
if (skyboxMaterial != null)
Destroy(skyboxMaterial);
if (videoPlayerObject != null)
Destroy(videoPlayerObject);
}
private void CreateSkyboxMaterial()
{
renderTexture = new RenderTexture(renderTextureSize, renderTextureSize / 2, 0, RenderTextureFormat.ARGB32)
{
name = "Game360VideoRenderTexture",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
};
renderTexture.Create();
previousSkybox = RenderSettings.skybox;
skyboxMaterial = new Material(ResolveSkyboxShader())
{
name = "Game360VideoMaterial",
};
skyboxMaterial.SetTexture("_MainTex", renderTexture);
skyboxMaterial.SetFloat("_ImageType", 0f);
skyboxMaterial.SetFloat("_Mapping", 0f);
skyboxMaterial.SetFloat("_Layout", 0f);
ApplySkyboxSettings();
RenderSettings.skybox = skyboxMaterial;
DynamicGI.UpdateEnvironment();
}
private void CreateVideoPlayer()
{
videoPlayerObject = new GameObject("[360 Video Skybox Player]");
videoPlayerObject.transform.SetParent(transform, false);
videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
videoPlayer.playOnAwake = false;
videoPlayer.isLooping = true;
videoPlayer.waitForFirstFrame = true;
videoPlayer.renderMode = VideoRenderMode.RenderTexture;
videoPlayer.targetTexture = renderTexture;
videoPlayer.clip = videoClip;
videoPlayer.audioOutputMode = muteVideoAudio
? VideoAudioOutputMode.None
: VideoAudioOutputMode.Direct;
videoPlayer.prepareCompleted += OnVideoPrepared;
videoPlayer.errorReceived += OnVideoError;
videoPlayer.Prepare();
}
private static Shader ResolveSkyboxShader()
{
return Shader.Find("Skybox/Panoramic")
?? Shader.Find("Skybox/6 Sided")
?? Shader.Find("Standard");
}
private void OnVideoPrepared(VideoPlayer source)
{
source.Play();
}
private void OnValidate()
{
ApplySkyboxSettings();
}
private void ApplySkyboxSettings()
{
if (skyboxMaterial == null)
return;
skyboxMaterial.SetFloat("_Exposure", skyboxExposure);
skyboxMaterial.SetFloat("_Rotation", skyboxRotationDegrees);
DynamicGI.UpdateEnvironment();
}
private static void OnVideoError(VideoPlayer source, string message)
{
Debug.LogWarning($"[Game360VideoBackground] VideoPlayer error: {message}");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e381cd99de84f67b9f83c19a032dc24
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
public static class GlobalSyncSettings
{
private const string AudioOffsetMsKey = "VRBeats.GlobalAudioOffsetMs";
public static float AudioOffsetMs
{
get => PlayerPrefs.GetFloat(AudioOffsetMsKey, 0.0f);
set
{
PlayerPrefs.SetFloat(AudioOffsetMsKey, Mathf.Clamp(value, -300.0f, 300.0f));
PlayerPrefs.Save();
}
}
public static float AudioOffsetSeconds => AudioOffsetMs / 1000.0f;
public static void Reset()
{
PlayerPrefs.DeleteKey(AudioOffsetMsKey);
PlayerPrefs.Save();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2e8c518ec2f4a03a6d820774b475ce0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93
View File
@@ -0,0 +1,93 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class MenuSyncButtonInjector : MonoBehaviour
{
private const string MenuSceneName = "Menu";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoCreate()
{
if (FindFirstObjectByType<MenuSyncButtonInjector>() != null)
return;
GameObject go = new GameObject("[MenuSyncButtonInjector]");
DontDestroyOnLoad(go);
go.AddComponent<MenuSyncButtonInjector>();
}
private void Awake()
{
SceneManager.sceneLoaded += OnSceneLoaded;
StartCoroutine(InjectAfterFrame());
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
StartCoroutine(InjectAfterFrame());
}
private IEnumerator InjectAfterFrame()
{
yield return null;
if (SceneManager.GetActiveScene().name != MenuSceneName)
yield break;
if (GameObject.Find("SyncButton") != null)
yield break;
Button sourceButton = FindButtonByText("CreateSong");
if (sourceButton == null)
sourceButton = FindButtonByText("음악만들기");
if (sourceButton == null)
yield break;
Button syncButton = Instantiate(sourceButton, sourceButton.transform.parent);
syncButton.gameObject.name = "SyncButton";
foreach (VRBeats.LoadSceneButton loader in syncButton.GetComponents<VRBeats.LoadSceneButton>())
{
loader.enabled = false;
Destroy(loader);
}
syncButton.onClick.RemoveAllListeners();
syncButton.onClick.AddListener(SyncCalibrationOverlay.Open);
RectTransform sourceRect = sourceButton.GetComponent<RectTransform>();
RectTransform syncRect = syncButton.GetComponent<RectTransform>();
syncRect.anchoredPosition = sourceRect.anchoredPosition + new Vector2(0.0f, -22.0f);
TextMeshProUGUI tmp = syncButton.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null)
tmp.text = "SYNC";
Text text = syncButton.GetComponentInChildren<Text>(true);
if (text != null)
text.text = "SYNC";
}
private static Button FindButtonByText(string text)
{
foreach (Button button in FindObjectsByType<Button>(FindObjectsSortMode.None))
{
TextMeshProUGUI tmp = button.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null && tmp.text.Trim() == text)
return button;
Text legacyText = button.GetComponentInChildren<Text>(true);
if (legacyText != null && legacyText.text.Trim() == text)
return button;
}
return null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: efc5a20a7b4749bfb60d95ac0f0b2180
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93 -20
View File
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Networking;
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
private void LoadConfig()
{
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
if (!File.Exists(path))
{
NormalizeSettings();
return;
}
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
if (cfg == null) return;
_password = cfg.password ?? "";
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
NormalizeSettings();
}
[Serializable] private class NasConfig
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
Action onComplete,
Action<string> onError)
{
NormalizeSettings();
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
@@ -92,31 +101,53 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError)
{
if (string.IsNullOrWhiteSpace(_password))
{
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
yield break;
}
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
UnityWebRequest req;
try
{
onError?.Invoke($"DSM login failed: {req.error}");
req = UnityWebRequest.Get(url);
}
catch (UriFormatException e)
{
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
yield break;
}
string resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
string resp;
using (req)
{
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"DSM login failed: {req.error}");
yield break;
}
resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
}
if (string.IsNullOrEmpty(_sid))
onError?.Invoke("DSM sid parse failed — check credentials.");
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
}
private IEnumerator Logout()
{
NormalizeSettings();
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url);
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError)
{
NormalizeSettings();
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{
NormalizeSettings();
SongsList list = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
return null;
Match match = Regex.Match(
json,
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
return match.Success
? Regex.Unescape(match.Groups["value"].Value)
: null;
}
private static void AssignMapFile(SongInfo song, string diff, string fileName)
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
var info = song.difficulties.Get(diff);
if (info != null) info.mapFile = $"maps/{fileName}";
}
private void OnValidate()
{
NormalizeSettings();
}
private void NormalizeSettings()
{
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
nasAccount = nasAccount?.Trim() ?? "";
nasRootPath = NormalizeRootPath(nasRootPath);
}
private static string NormalizeBaseUrl(string value)
{
return (value ?? "").Trim().TrimEnd('/');
}
private static string NormalizeRootPath(string value)
{
value = (value ?? "").Trim().Replace('\\', '/');
if (string.IsNullOrEmpty(value))
return "/";
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
}
private static string Shorten(string value, int maxLength = 240)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value ?? "";
return value.Substring(0, maxLength) + "...";
}
}
+23 -7
View File
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
private const float VerticalCenter = 1f;
private AudioManager _audio;
private ScoreManager _scoreManager;
private float _clipLength;
private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber");
Path.Combine(Application.persistentDataPath, "beatsaber");
private void Start()
{
_audio = FindFirstObjectByType<AudioManager>();
_scoreManager = FindFirstObjectByType<ScoreManager>();
StartCoroutine(LoadAndPlay());
}
private void Update()
{
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
}
private IEnumerator LoadAndPlay()
{
SongInfo song = GameSession.SelectedSong;
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
}
clip = DownloadHandlerAudioClip.GetContent(req);
}
_clipLength = clip.length;
// Load and parse map
DifficultyInfo diffInfo = song.difficulties.Get(diff);
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
yield break;
}
map.target.Sort(CompareNotes);
_scoreManager?.SetTotalNotes(map.target.Count);
yield return StartCoroutine(Countdown());
_audio.PlayClip(clip);
StartCoroutine(SpawnRoutine(map.target));
yield return StartCoroutine(WaitForCompletion(clip.length));
yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
}
private IEnumerator Countdown()
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
foreach (NoteData note in notes)
{
float spawnAt = Mathf.Max(0f, note.time - travelTime);
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
SpawnNote(note);
}
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
float y = MapLayerY(note.lineLayer);
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
float remaining = note.time - _audio.CurrentTime;
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
float travelTime = Mathf.Max(0.05f, remaining);
var info = new SpawnEventInfo
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
position = new Vector3(x, y, 0f),
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
hitDirection = MapCutDirection(note.cutDirection),
useSpark = true,
useSpark = false,
speed = 2f,
travelTimeOverride = travelTime,
};
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
private static Direction MapCutDirection(int cut)
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
private IEnumerator WaitForCompletion(float clipLength)
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
{
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
yield return new WaitForSeconds(0.35f);
_scoreManager?.CompleteSong();
onLevelComplete?.Invoke();
}
}
+341
View File
@@ -0,0 +1,341 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.XR;
using XRCommonUsages = UnityEngine.XR.CommonUsages;
using XRInputDevice = UnityEngine.XR.InputDevice;
public class SyncCalibrationOverlay : MonoBehaviour
{
private const float TickInterval = 1.0f;
private const int MaxSamples = 8;
private static Scene pendingReturnScene;
private readonly List<float> samples = new();
private readonly List<(Canvas canvas, bool enabled)> hiddenCanvases = new();
private AudioSource audioSource = null;
private AudioClip tickClip = null;
private RectTransform sweepDot = null;
private RectTransform visualPulse = null;
private TextMeshProUGUI offsetText = null;
private TextMeshProUGUI sampleText = null;
private TextMeshProUGUI guideText = null;
private float lastTickTime = 0.0f;
private float nextTickTime = 0.0f;
private float pendingVisualPulseTime = -1.0f;
private float visualPulseTimer = 0.0f;
private bool previousPrimary = false;
private bool previousSecondary = false;
private Scene returnScene;
private Scene syncScene;
private bool canvasesRestored = false;
public static void Open()
{
if (FindFirstObjectByType<SyncCalibrationOverlay>() != null)
return;
pendingReturnScene = SceneManager.GetActiveScene();
Scene calibrationScene = SceneManager.CreateScene("SyncCalibration");
SceneManager.SetActiveScene(calibrationScene);
GameObject overlay = new GameObject("[SyncCalibrationOverlay]");
overlay.AddComponent<SyncCalibrationOverlay>();
}
private void Awake()
{
syncScene = gameObject.scene;
returnScene = pendingReturnScene;
HideExistingCanvases();
BuildView();
tickClip = CreateTickClip();
audioSource = gameObject.AddComponent<AudioSource>();
audioSource.playOnAwake = false;
audioSource.spatialBlend = 0.0f;
float now = Time.unscaledTime;
lastTickTime = now;
nextTickTime = now + 0.8f;
UpdateTexts();
}
private void OnDestroy()
{
RestoreCanvases();
}
private void Update()
{
float now = Time.unscaledTime;
if (now >= nextTickTime)
{
lastTickTime = nextTickTime;
nextTickTime += TickInterval;
audioSource.PlayOneShot(tickClip);
pendingVisualPulseTime = lastTickTime + GlobalSyncSettings.AudioOffsetSeconds;
}
if (pendingVisualPulseTime > 0.0f && now >= pendingVisualPulseTime)
{
visualPulseTimer = 0.18f;
pendingVisualPulseTime = -1.0f;
}
UpdateMetronomeVisual(now);
HandleInput(now);
}
private void HandleInput(float now)
{
bool primary = GetRightButton(XRCommonUsages.primaryButton) || IsKeyboardPressed(Key.Space);
bool secondary = GetRightButton(XRCommonUsages.secondaryButton) || IsKeyboardPressed(Key.Escape);
if (primary && !previousPrimary)
CaptureSample(now);
if (secondary && !previousSecondary)
Close();
previousPrimary = primary;
previousSecondary = secondary;
}
private void CaptureSample(float now)
{
float nearestTick = Mathf.Abs(now - lastTickTime) <= Mathf.Abs(now - nextTickTime)
? lastTickTime
: nextTickTime;
float offsetMs = Mathf.Clamp((now - nearestTick) * 1000.0f, -300.0f, 300.0f);
samples.Add(offsetMs);
if (samples.Count > MaxSamples)
samples.RemoveAt(0);
float sum = 0.0f;
for (int i = 0; i < samples.Count; i++)
sum += samples[i];
GlobalSyncSettings.AudioOffsetMs = sum / samples.Count;
UpdateTexts();
}
private void AdjustOffset(float deltaMs)
{
GlobalSyncSettings.AudioOffsetMs += deltaMs;
samples.Clear();
UpdateTexts();
}
private void ResetOffset()
{
GlobalSyncSettings.Reset();
samples.Clear();
UpdateTexts();
}
private void Close()
{
RestoreCanvases();
if (returnScene.IsValid() && returnScene.isLoaded)
SceneManager.SetActiveScene(returnScene);
if (syncScene.IsValid() && syncScene.isLoaded)
SceneManager.UnloadSceneAsync(syncScene);
else
Destroy(gameObject);
}
private void BuildView()
{
Camera camera = Camera.main ?? FindFirstObjectByType<Camera>();
GameObject canvasObject = new GameObject("SyncCalibrationCanvas");
canvasObject.transform.SetParent(transform, false);
Canvas canvas = canvasObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 400;
canvas.worldCamera = camera;
canvasObject.AddComponent<GraphicRaycaster>();
RectTransform canvasRect = canvasObject.GetComponent<RectTransform>();
canvasRect.sizeDelta = new Vector2(1080.0f, 660.0f);
canvasObject.transform.localScale = Vector3.one * 0.0028f;
if (camera != null)
{
Transform camTransform = camera.transform;
canvasObject.transform.position = camTransform.position + camTransform.forward * 1.9f;
canvasObject.transform.rotation = Quaternion.LookRotation(canvasObject.transform.position - camTransform.position, camTransform.up);
}
Image background = CreateImage("Panel", canvasRect, new Vector2(0, 0), new Vector2(1020, 620), new Color(0.02f, 0.06f, 0.09f, 0.92f));
background.raycastTarget = false;
TextMeshProUGUI title = CreateText("Title", canvasRect, "SYNC CALIBRATION", new Vector2(0, 260), new Vector2(920, 58), 42, Color.white, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
guideText = CreateText("Guide", canvasRect, "", new Vector2(0, 180), new Vector2(920, 92), 26, new Color(0.77f, 0.9f, 1.0f, 1.0f), TextAlignmentOptions.Center);
Image bar = CreateImage("BeatBar", canvasRect, new Vector2(0, 82), new Vector2(760, 10), new Color(0.25f, 0.85f, 1.0f, 0.28f));
bar.raycastTarget = false;
sweepDot = CreateImage("BeatDot", canvasRect, new Vector2(-380, 82), new Vector2(34, 34), new Color(0.35f, 0.95f, 1.0f, 1.0f)).rectTransform;
visualPulse = CreateImage("VisualPulse", canvasRect, new Vector2(0, 82), new Vector2(140, 140), new Color(0.35f, 0.95f, 1.0f, 0.0f)).rectTransform;
offsetText = CreateText("Offset", canvasRect, "", new Vector2(0, 0), new Vector2(720, 74), 54, Color.white, TextAlignmentOptions.Center);
offsetText.fontStyle = FontStyles.Bold;
sampleText = CreateText("Samples", canvasRect, "", new Vector2(0, -72), new Vector2(760, 44), 24, new Color(0.65f, 0.78f, 0.84f, 1.0f), TextAlignmentOptions.Center);
CreateButton(canvasRect, "-10ms", new Vector2(-300, -165), () => AdjustOffset(-10.0f));
CreateButton(canvasRect, "+10ms", new Vector2(-100, -165), () => AdjustOffset(10.0f));
CreateButton(canvasRect, "RESET", new Vector2(100, -165), ResetOffset);
CreateButton(canvasRect, "BACK", new Vector2(300, -165), Close);
CreateText("Footer", canvasRect, "A / Space: capture beat B / Esc: back", new Vector2(0, -270), new Vector2(860, 40), 22, new Color(0.58f, 0.7f, 0.75f, 1.0f), TextAlignmentOptions.Center);
}
private void UpdateMetronomeVisual(float now)
{
float phase = Mathf.InverseLerp(lastTickTime, nextTickTime, now);
if (sweepDot != null)
sweepDot.anchoredPosition = new Vector2(Mathf.Lerp(-380.0f, 380.0f, phase), 82.0f);
if (visualPulse == null)
return;
visualPulseTimer = Mathf.Max(0.0f, visualPulseTimer - Time.unscaledDeltaTime);
float alpha = visualPulseTimer / 0.18f;
visualPulse.sizeDelta = Vector2.one * Mathf.Lerp(190.0f, 80.0f, alpha);
Image image = visualPulse.GetComponent<Image>();
if (image != null)
image.color = new Color(0.35f, 0.95f, 1.0f, alpha * 0.52f);
}
private void UpdateTexts()
{
float offset = GlobalSyncSettings.AudioOffsetMs;
if (offsetText != null)
offsetText.text = $"{offset:+0;-0;0} ms";
if (sampleText != null)
sampleText.text = $"samples {samples.Count}/{MaxSamples} global offset saved";
if (guideText != null)
guideText.text = "Tick 소리가 들리는 순간 A / Space를 누르세요.\n파란 원이 박자와 겹치면 보정이 맞습니다.";
}
private void HideExistingCanvases()
{
hiddenCanvases.Clear();
foreach (Canvas canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
hiddenCanvases.Add((canvas, canvas.enabled));
canvas.enabled = false;
}
}
private void RestoreCanvases()
{
if (canvasesRestored)
return;
canvasesRestored = true;
foreach ((Canvas canvas, bool enabled) in hiddenCanvases)
{
if (canvas != null)
canvas.enabled = enabled;
}
}
private static TextMeshProUGUI CreateText(string name, RectTransform parent, string value, Vector2 position, Vector2 size, int fontSize, Color color, TextAlignmentOptions alignment)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
text.text = value;
text.fontSize = fontSize;
text.color = color;
text.alignment = alignment;
text.textWrappingMode = TextWrappingModes.Normal;
text.overflowMode = TextOverflowModes.Overflow;
text.raycastTarget = false;
return text;
}
private static Image CreateImage(string name, RectTransform parent, Vector2 position, Vector2 size, Color color)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
Image image = go.AddComponent<Image>();
image.color = color;
return image;
}
private static void CreateButton(RectTransform parent, string label, Vector2 position, UnityEngine.Events.UnityAction action)
{
Image image = CreateImage(label + "Button", parent, position, new Vector2(168, 58), new Color(0.07f, 0.18f, 0.24f, 0.96f));
Button button = image.gameObject.AddComponent<Button>();
button.onClick.AddListener(action);
ColorBlock colors = button.colors;
colors.normalColor = new Color(0.07f, 0.18f, 0.24f, 0.96f);
colors.highlightedColor = new Color(0.13f, 0.38f, 0.48f, 1.0f);
colors.pressedColor = new Color(0.08f, 0.72f, 0.85f, 1.0f);
colors.selectedColor = colors.highlightedColor;
button.colors = colors;
TextMeshProUGUI labelText = CreateText(label + "Text", image.rectTransform, label, Vector2.zero, new Vector2(154, 48), 24, Color.white, TextAlignmentOptions.Center);
labelText.fontStyle = FontStyles.Bold;
}
private static bool GetRightButton(InputFeatureUsage<bool> usage)
{
var devices = new List<XRInputDevice>();
InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller | InputDeviceCharacteristics.Right, devices);
if (devices.Count == 0)
return false;
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private static bool IsKeyboardPressed(Key key)
{
Keyboard keyboard = Keyboard.current;
return keyboard != null && keyboard[key].isPressed;
}
private static AudioClip CreateTickClip()
{
const int sampleRate = 48000;
const float duration = 0.055f;
int sampleCount = Mathf.CeilToInt(sampleRate * duration);
float[] data = new float[sampleCount];
for (int i = 0; i < sampleCount; i++)
{
float t = (float)i / sampleRate;
float envelope = Mathf.Exp(-t * 62.0f);
float high = Mathf.Sin(2.0f * Mathf.PI * 1760.0f * t);
float click = i < 80 ? 1.0f - (float)i / 80.0f : 0.0f;
data[i] = Mathf.Clamp((high * 0.75f + click * 0.35f) * envelope, -1.0f, 1.0f);
}
AudioClip clip = AudioClip.Create("SyncTick", sampleCount, 1, sampleRate, false);
clip.SetData(data, 0);
return clip;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 90f8d18d467240d8bb84178048a5aa91
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+258 -24
View File
@@ -11,10 +11,20 @@ namespace VRBeats
{
[SerializeField] private bool isRightHand = true;
[SerializeField] private float maxDistance = 50f;
[SerializeField] private bool debugLogging = false;
[SerializeField] private float scrollSpeed = 2.4f;
[SerializeField] private float scrollDeadZone = 0.15f;
[SerializeField] private float dragScrollSpeed = 1.25f;
[SerializeField] private float dragClickThreshold = 0.025f;
private LineRenderer _line;
private bool _prevTrigger;
private Selectable _currentHover;
private ScrollRect _dragScrollRect;
private Selectable _triggerPressSelectable;
private Vector2 _dragStartLocalPoint;
private float _dragStartNormalizedPosition;
private float _dragMaxNormalizedDelta;
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
@@ -40,7 +50,8 @@ namespace VRBeats
private void Start()
{
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
if (debugLogging)
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
}
private void OnEnable()
@@ -56,11 +67,14 @@ namespace VRBeats
private void Update()
{
// 3초마다 연결된 디바이스 목록 출력
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
if (debugLogging)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
}
bool trigger = GetButton(CommonUsages.triggerButton);
@@ -70,31 +84,41 @@ namespace VRBeats
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick;
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
string hand = isRightHand ? "R" : "L";
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown)Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
if (debugLogging)
{
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward);
float hitDist = maxDistance;
float selectableHitDist = maxDistance;
float scrollHitDist = maxDistance;
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
bool beganScrollDrag = false;
if (triggerDown)
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
if (_dragScrollRect != null && trigger)
UpdateScrollDrag(ray);
else if (_dragScrollRect == null)
HandleScroll(scrollRect);
// 호버 변화 로그
if (hit != _currentHover)
if (debugLogging && hit != _currentHover)
{
Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}"
@@ -103,16 +127,21 @@ namespace VRBeats
UpdateHoverState(hit);
// 검지 트리거 또는 A/X 버튼으로 클릭
if (triggerDown || primaryDown)
if (triggerUp && _dragScrollRect != null)
EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{
if (_currentHover != null)
{
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover);
}
else
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
@@ -121,6 +150,12 @@ namespace VRBeats
}
DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
}
private void LogConnectedDevices()
@@ -222,6 +257,7 @@ namespace VRBeats
foreach (Selectable sel in Selectable.allSelectablesArray)
{
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue;
@@ -260,6 +296,189 @@ namespace VRBeats
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
}
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
{
ScrollRect closest = null;
float closestDist = maxDist;
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
foreach (ScrollRect scroll in all)
{
if (!scroll.isActiveAndEnabled) continue;
if (!IsOnEnabledCanvas(scroll)) continue;
RectTransform rt = scroll.viewport != null
? scroll.viewport
: scroll.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = scroll;
}
if (closest != null)
maxDist = closestDist;
return closest;
}
private static bool IsOnEnabledCanvas(Component component)
{
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
if (canvases.Length == 0)
return true;
for (int i = 0; i < canvases.Length; i++)
{
Canvas canvas = canvases[i];
if (canvas == null)
continue;
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
return false;
}
return true;
}
private void HandleScroll(ScrollRect scrollRect)
{
if (!CanScrollVertically(scrollRect))
return;
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
if (Mathf.Abs(axis.y) < scrollDeadZone)
return;
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
}
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
{
if (!CanScrollVertically(scrollRect))
return false;
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
return false;
_dragScrollRect = scrollRect;
_triggerPressSelectable = pressSelectable;
_dragStartLocalPoint = localPoint;
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
_dragMaxNormalizedDelta = 0f;
return true;
}
private void UpdateScrollDrag(Ray ray)
{
if (_dragScrollRect == null)
return;
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
return;
float deltaY = localPoint.y - _dragStartLocalPoint.y;
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
_dragStartNormalizedPosition - normalizedDelta);
}
private void EndScrollDrag(string hand, Ray ray)
{
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
ScrollRect scrollRect = _dragScrollRect;
Selectable pressSelectable = _triggerPressSelectable;
float startNormalizedPosition = _dragStartNormalizedPosition;
ClearScrollDrag();
if (!shouldClick)
return;
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
{
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
Click(pressSelectable);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(ray);
}
}
private void ClearScrollDrag()
{
_dragScrollRect = null;
_triggerPressSelectable = null;
_dragStartLocalPoint = Vector2.zero;
_dragStartNormalizedPosition = 0f;
_dragMaxNormalizedDelta = 0f;
}
private static bool CanScrollVertically(ScrollRect scrollRect)
{
if (scrollRect == null || !scrollRect.vertical)
return false;
RectTransform viewport = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (viewport == null || scrollRect.content == null)
return true;
return scrollRect.content.rect.height > viewport.rect.height + 1f;
}
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
{
localPoint = Vector2.zero;
viewportHeight = 1f;
RectTransform rt = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (rt == null)
return false;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
return false;
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
localPoint = new Vector2(local.x, local.y);
viewportHeight = Mathf.Max(1f, rt.rect.height);
return true;
}
private bool GetButton(InputFeatureUsage<bool> usage)
{
var chars = InputDeviceCharacteristics.Controller |
@@ -274,5 +493,20 @@ namespace VRBeats
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return Vector2.zero;
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
return axis;
}
}
}
+14 -1
View File
@@ -80,6 +80,9 @@ namespace VRBeats
if (!isRight && !isLeft) continue;
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
if (go.GetComponent<LineRenderer>() == null) continue;
DisableToolkitPointerComponents(go);
if (go.GetComponent<VRPointerController>() != null) continue;
var pointer = go.AddComponent<VRPointerController>();
@@ -94,8 +97,18 @@ namespace VRBeats
if (disabledByDefault)
pointer.enabled = false;
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
}
}
private static void DisableToolkitPointerComponents(GameObject go)
{
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
if (rayInteractor != null)
rayInteractor.enabled = false;
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
if (lineVisual != null)
lineVisual.enabled = false;
}
}
}
@@ -44,28 +44,6 @@ MonoBehaviour:
balance:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-8104416584915340131
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent2
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent2
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p21:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-7750755424749557576
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -239,19 +217,6 @@ MonoBehaviour:
maxNits:
m_OverrideState: 1
m_Value: 1000
--- !u!114 &-5360449096862653589
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedEverywhere
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedEverywhere
active: 1
--- !u!114 &-5139089513906902183
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -408,28 +373,6 @@ MonoBehaviour:
tint:
m_OverrideState: 1
m_Value: 0
--- !u!114 &-581120513425526550
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent3
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent3
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
p31:
m_OverrideState: 1
m_Value: {r: 0, g: 0, b: 0, a: 1}
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -852,19 +795,6 @@ MonoBehaviour:
intensity:
m_OverrideState: 1
m_Value: 0
--- !u!114 &6940869943325143175
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: VolumeComponentSupportedOnAnySRP
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEngine.Rendering.Tests:VolumeComponentEditorSupportedOnTests/VolumeComponentSupportedOnAnySRP
active: 1
--- !u!114 &7173750748008157695
MonoBehaviour:
m_ObjectHideFlags: 3
@@ -961,22 +891,3 @@ MonoBehaviour:
blueOutBlueIn:
m_OverrideState: 1
m_Value: 100
--- !u!114 &9122958982931076880
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 0}
m_Name: CopyPasteTestComponent1
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Editor.Tests:UnityEditor.Rendering.Tests:VolumeComponentCopyPasteTests/CopyPasteTestComponent1
active: 1
p1:
m_OverrideState: 1
m_Value: 0
p2:
m_OverrideState: 1
m_Value: 0
@@ -33,7 +33,8 @@ MonoBehaviour:
m_defaultTextMeshProUITextContainerSize: {x: 200, y: 50}
m_autoSizeTextContainer: 0
m_IsTextObjectScaleStatic: 0
m_fallbackFontAssets: []
m_fallbackFontAssets:
- {fileID: 11400000, guid: f6c6fe0f3c5912a43a8a6707e336d2ea, type: 2}
m_matchMaterialPreset: 1
m_HideSubTextObjects: 1
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
+168
View File
@@ -0,0 +1,168 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: SkyBox
m_Shader: {fileID: 108, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords:
- _MAPPING_LATITUDE_LONGITUDE_LAYOUT
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BackTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DownTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _FrontTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _LeftTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _RightTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _UpTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _Exposure: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _ImageType: 0
- _Layout: 0
- _Mapping: 1
- _Metallic: 0
- _MirrorOnBack: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Rotation: 0
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1}
- _Tint: {r: 0.5, g: 0.5, b: 0.5, a: 0.5}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &5645475041611047199
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fecd661b14876064fa838cbb52ca425e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -76,7 +76,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -170,7 +170,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -528,7 +528,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694296687208}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -760,7 +760,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6803edce0201f574f923fd9d10e5b30a, type: 3}
m_Name:
@@ -854,7 +854,7 @@ MonoBehaviour:
m_AllowHoveredActivate: 0
m_TargetPriorityMode: 0
m_HideControllerOnSelect: 0
m_InputCompatibilityMode: 0
m_InputCompatibilityMode: 2
m_PlayAudioClipOnSelectEntered: 0
m_AudioClipForOnSelectEntered: {fileID: 0}
m_PlayAudioClipOnSelectExited: 0
@@ -1212,7 +1212,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 748387694868252494}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -411,7 +411,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668446538128435}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1003,7 +1003,7 @@ MonoBehaviour:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8546668447105775893}
m_Enabled: 1
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e988983f96fe1dd48800bcdfc82f23e9, type: 3}
m_Name:
@@ -1295,7 +1295,7 @@ Camera:
m_GameObject: {fileID: 8546668447772986810}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
@@ -342,7 +342,7 @@ MonoBehaviour:
handSettings:
interactPoint: {fileID: 3074267110786978836}
highlightPoint: {fileID: 3074267110786978836}
rotationOffset: {x: 0, y: 90, z: 25}
rotationOffset: {x: 25, y: 0, z: 0}
canInteract: 1
rightHandAnimationSettings:
animation: {fileID: 0}
@@ -522,7 +522,7 @@ MonoBehaviour:
m_GameObject: {fileID: 5575416034875238503}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c68346ff56573a1429560a527ad447e0, type: 3}
m_Script: {fileID: 11500000, guid: 4de824eda67bd1c4ba4d379a9debd2b3, type: 3}
m_Name:
m_EditorClassIdentifier:
fastCollisionListener: {fileID: 5407220909436794986}
@@ -533,6 +533,7 @@ MonoBehaviour:
hitForce: 0
maxHitForce: 0
canDismember: 0
colorSide: 1
--- !u!114 &5407220909436794986
MonoBehaviour:
m_ObjectHideFlags: 0
+4 -4
View File
@@ -2639,7 +2639,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 661667650}
m_LocalRotation: {x: 0, y: 0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 19.76}
m_LocalPosition: {x: 0, y: 0, z: 20.29}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2652,7 +2652,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 24.54, y: 4.415}
m_AnchoredPosition: {x: 24.01, y: 4.71}
m_SizeDelta: {x: 105.885, y: 71.226}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &661667652
@@ -7157,7 +7157,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1946485404}
m_LocalRotation: {x: 0, y: -0.38268343, z: 0, w: 0.92387956}
m_LocalPosition: {x: 0, y: 0, z: 17.9}
m_LocalPosition: {x: 0, y: 0, z: 18.33}
m_LocalScale: {x: 0.25, y: 0.25, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -7167,7 +7167,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: -45, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -22.2, y: 4.39}
m_AnchoredPosition: {x: -21.77, y: 4.39}
m_SizeDelta: {x: 105.89, y: 66.53}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1946485406
@@ -39,6 +39,11 @@ namespace VRBeats
if (Cut(info.hitPoint, cutDir, insideMaterial))
{
Color trailColor = beatCube != null
? VR_BeatManager.instance.GetColorFromColorSide(beatCube.ThisColorSide)
: Color.white;
Vector3 saberUp = beatDamageInfo.hitObject != null ? beatDamageInfo.hitObject.transform.up : cutDir;
SliceTrailEffect.Spawn(info.hitPoint, info.hitDir, saberUp, trailColor);
Destroy(gameObject);
}
}
@@ -0,0 +1,450 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace VRBeats
{
/// <summary>
/// Builds a short world-space ribbon from the whole saber blade instead of
/// attaching a TrailRenderer to the tip. This keeps the afterimage on the
/// blade surface and avoids the "propeller" look from a single end point.
/// </summary>
public class SaberTrailEffect : MonoBehaviour
{
private const int MaxSamples = 18;
private const float TrailLifetime = 0.13f;
private const float MinSwingSpeed = 0.75f;
private const float MinSampleDistance = 0.012f;
private const float BladeBaseT = 0.10f;
private const float BladeTipT = 0.98f;
private readonly List<BladeSample> samples = new List<BladeSample>(MaxSamples);
private readonly List<Vector3> vertices = new List<Vector3>(MaxSamples * 2);
private readonly List<Color> colors = new List<Color>(MaxSamples * 2);
private readonly List<Vector2> uvs = new List<Vector2>(MaxSamples * 2);
private readonly List<int> triangles = new List<int>((MaxSamples - 1) * 12);
private Transform bladeStart;
private Transform bladeEnd;
private GameObject wideObject;
private GameObject coreObject;
private Mesh wideMesh;
private Mesh coreMesh;
private MeshRenderer wideRenderer;
private MeshRenderer coreRenderer;
private Material wideMaterial;
private Material coreMaterial;
private Color trailColor = Color.cyan;
private bool visible = true;
private bool hasLastFrame;
private Vector3 lastBase;
private Vector3 lastTip;
private struct BladeSample
{
public Vector3 basePos;
public Vector3 tipPos;
public float time;
}
private void Awake()
{
RemoveLegacyTrailRenderers();
ResolveBladeAnchors();
EnsureRenderers();
SetColor(trailColor);
SetVisible(visible);
}
private void LateUpdate()
{
if (!visible)
{
Clear();
hasLastFrame = false;
return;
}
if (bladeEnd == null)
{
ResolveBladeAnchors();
}
Vector3 basePos;
Vector3 tipPos;
GetBladeSegment(out basePos, out tipPos);
float now = Time.time;
float speed = 0f;
float moved = 0f;
if (hasLastFrame)
{
float deltaTime = Mathf.Max(Time.deltaTime, 0.0001f);
speed = Mathf.Max(
Vector3.Distance(basePos, lastBase),
Vector3.Distance(tipPos, lastTip)) / deltaTime;
moved = Mathf.Max(
Vector3.Distance(basePos, lastBase),
Vector3.Distance(tipPos, lastTip));
}
if (!hasLastFrame || (speed >= MinSwingSpeed && moved >= MinSampleDistance))
{
AddSample(basePos, tipPos, now);
}
TrimExpiredSamples(now);
BuildRibbon(wideMesh, now, 0f, 1f, trailColor, 0.10f, 0.36f);
Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f);
BuildRibbon(coreMesh, now, 0.42f, 1f, coreColor, 0.14f, 0.48f);
lastBase = basePos;
lastTip = tipPos;
hasLastFrame = true;
}
private void OnDisable()
{
Clear();
hasLastFrame = false;
}
private void OnDestroy()
{
DestroyRuntimeObject(wideObject);
DestroyRuntimeObject(coreObject);
DestroyRuntimeObject(wideMesh);
DestroyRuntimeObject(coreMesh);
DestroyRuntimeObject(wideMaterial);
DestroyRuntimeObject(coreMaterial);
}
public void SetVisible(bool value)
{
visible = value;
if (wideRenderer != null)
{
wideRenderer.enabled = value;
}
if (coreRenderer != null)
{
coreRenderer.enabled = value;
}
if (!value)
{
Clear();
hasLastFrame = false;
}
}
public void SetColor(Color color)
{
trailColor = NormalizeColor(color);
EnsureRenderers();
ApplyMaterialColor(wideMaterial, trailColor, 0.34f);
Color coreColor = Color.Lerp(Color.white, trailColor, 0.45f);
ApplyMaterialColor(coreMaterial, coreColor, 0.50f);
}
private void ResolveBladeAnchors()
{
bladeStart = FindChildRecursive(transform, "Start");
bladeEnd = FindChildRecursive(transform, "End");
if (bladeEnd == null)
{
bladeEnd = transform;
}
if (bladeStart == null)
{
bladeStart = transform;
}
}
private void GetBladeSegment(out Vector3 basePos, out Vector3 tipPos)
{
Vector3 start = bladeStart != null ? bladeStart.position : transform.position;
Vector3 end = bladeEnd != null ? bladeEnd.position : transform.position + transform.forward;
if (Vector3.Distance(start, end) < 0.001f)
{
end = start + transform.forward * 0.8f;
}
basePos = Vector3.Lerp(start, end, BladeBaseT);
tipPos = Vector3.Lerp(start, end, BladeTipT);
}
private void AddSample(Vector3 basePos, Vector3 tipPos, float time)
{
if (samples.Count >= MaxSamples)
{
samples.RemoveAt(0);
}
samples.Add(new BladeSample
{
basePos = basePos,
tipPos = tipPos,
time = time
});
}
private void TrimExpiredSamples(float now)
{
for (int i = samples.Count - 1; i >= 0; i--)
{
if (now - samples[i].time > TrailLifetime)
{
samples.RemoveAt(i);
}
}
}
private void BuildRibbon(Mesh mesh, float now, float innerT, float outerT, Color color, float innerAlpha, float outerAlpha)
{
if (mesh == null)
{
return;
}
vertices.Clear();
colors.Clear();
uvs.Clear();
triangles.Clear();
if (samples.Count < 2)
{
mesh.Clear();
return;
}
for (int i = 0; i < samples.Count; i++)
{
BladeSample sample = samples[i];
float age01 = Mathf.Clamp01((now - sample.time) / TrailLifetime);
float fade = Mathf.Pow(1f - age01, 1.7f);
float along = samples.Count <= 1 ? 0f : i / (float)(samples.Count - 1);
Vector3 inner = Vector3.Lerp(sample.basePos, sample.tipPos, innerT);
Vector3 outer = Vector3.Lerp(sample.basePos, sample.tipPos, outerT);
vertices.Add(inner);
vertices.Add(outer);
colors.Add(WithAlpha(color, fade * innerAlpha));
colors.Add(WithAlpha(color, fade * outerAlpha));
uvs.Add(new Vector2(0f, along));
uvs.Add(new Vector2(1f, along));
}
for (int i = 0; i < samples.Count - 1; i++)
{
int a = i * 2;
int b = a + 1;
int c = a + 2;
int d = a + 3;
triangles.Add(a);
triangles.Add(b);
triangles.Add(c);
triangles.Add(b);
triangles.Add(d);
triangles.Add(c);
triangles.Add(c);
triangles.Add(b);
triangles.Add(a);
triangles.Add(c);
triangles.Add(d);
triangles.Add(b);
}
mesh.Clear();
mesh.SetVertices(vertices);
mesh.SetColors(colors);
mesh.SetUVs(0, uvs);
mesh.SetTriangles(triangles, 0);
mesh.RecalculateBounds();
}
private void EnsureRenderers()
{
if (wideObject == null)
{
wideRenderer = CreateRibbonObject("SaberBladeAfterimage_Wide", out wideObject, out wideMesh, out wideMaterial, false);
}
if (coreObject == null)
{
coreRenderer = CreateRibbonObject("SaberBladeAfterimage_Core", out coreObject, out coreMesh, out coreMaterial, true);
}
}
private MeshRenderer CreateRibbonObject(string objectName, out GameObject ribbonObject, out Mesh mesh, out Material material, bool additive)
{
ribbonObject = new GameObject(objectName);
ribbonObject.hideFlags = HideFlags.DontSave;
ribbonObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
ribbonObject.transform.localScale = Vector3.one;
mesh = new Mesh
{
name = objectName + " Mesh"
};
mesh.MarkDynamic();
MeshFilter filter = ribbonObject.AddComponent<MeshFilter>();
filter.sharedMesh = mesh;
MeshRenderer renderer = ribbonObject.AddComponent<MeshRenderer>();
material = CreateTrailMaterial(objectName + " Material", additive);
renderer.sharedMaterial = material;
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.receiveShadows = false;
renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
renderer.enabled = visible;
return renderer;
}
private Material CreateTrailMaterial(string materialName, bool additive)
{
Shader shader = Shader.Find("Sprites/Default");
if (shader == null)
{
shader = Shader.Find("Unlit/Transparent");
}
Material material = new Material(shader)
{
name = materialName,
hideFlags = HideFlags.DontSave,
renderQueue = (int)RenderQueue.Transparent
};
if (material.HasProperty("_SrcBlend"))
{
material.SetInt("_SrcBlend", additive ? (int)BlendMode.SrcAlpha : (int)BlendMode.SrcAlpha);
}
if (material.HasProperty("_DstBlend"))
{
material.SetInt("_DstBlend", additive ? (int)BlendMode.One : (int)BlendMode.OneMinusSrcAlpha);
}
if (material.HasProperty("_ZWrite"))
{
material.SetInt("_ZWrite", 0);
}
return material;
}
private void ApplyMaterialColor(Material material, Color color, float alpha)
{
if (material == null)
{
return;
}
Color materialColor = WithAlpha(color, alpha);
if (material.HasProperty("_Color"))
{
material.SetColor("_Color", materialColor);
}
}
private void Clear()
{
samples.Clear();
if (wideMesh != null)
{
wideMesh.Clear();
}
if (coreMesh != null)
{
coreMesh.Clear();
}
}
private void RemoveLegacyTrailRenderers()
{
TrailRenderer[] legacyTrails = GetComponentsInChildren<TrailRenderer>(true);
for (int i = legacyTrails.Length - 1; i >= 0; i--)
{
DestroyRuntimeObject(legacyTrails[i]);
}
}
private static Transform FindChildRecursive(Transform root, string childName)
{
if (root == null)
{
return null;
}
if (root.name == childName)
{
return root;
}
for (int i = 0; i < root.childCount; i++)
{
Transform found = FindChildRecursive(root.GetChild(i), childName);
if (found != null)
{
return found;
}
}
return null;
}
private static Color NormalizeColor(Color color)
{
float max = Mathf.Max(color.r, color.g, color.b);
if (max > 1f)
{
color.r /= max;
color.g /= max;
color.b /= max;
}
color.a = 1f;
return color;
}
private static Color WithAlpha(Color color, float alpha)
{
color.a = Mathf.Clamp01(alpha);
return color;
}
private static void DestroyRuntimeObject(Object target)
{
if (target == null)
{
return;
}
if (Application.isPlaying)
{
Destroy(target);
}
else
{
DestroyImmediate(target);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f4ab89ec5474db497559af138ed0a6f
@@ -0,0 +1,140 @@
using System.Collections;
using UnityEngine;
using UnityEngine.Rendering;
namespace VRBeats
{
public class SliceTrailEffect : MonoBehaviour
{
private const float Lifetime = 0.24f;
private const int PointCount = 7;
private LineRenderer glowLine = null;
private LineRenderer coreLine = null;
private Vector3 center = Vector3.zero;
private Vector3 tangent = Vector3.right;
private Vector3 lift = Vector3.up;
private Color effectColor = Color.cyan;
public static void Spawn(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
{
GameObject effectObject = new GameObject("SliceTrailEffect");
SliceTrailEffect effect = effectObject.AddComponent<SliceTrailEffect>();
effect.Construct(position, hitDir, saberUp, color);
}
private void Construct(Vector3 position, Vector3 hitDir, Vector3 saberUp, Color color)
{
center = position;
transform.position = position;
effectColor = NormalizeColor(color);
tangent = saberUp.sqrMagnitude > 0.001f ? saberUp.normalized : Vector3.right;
lift = hitDir.sqrMagnitude > 0.001f ? hitDir.normalized : Vector3.up;
glowLine = CreateLine("Glow", 0.16f, 0.45f);
coreLine = CreateLine("Core", 0.045f, 0.95f);
StartCoroutine(Animate());
}
private LineRenderer CreateLine(string name, float width, float alpha)
{
GameObject lineObject = new GameObject(name);
lineObject.transform.SetParent(transform, false);
LineRenderer line = lineObject.AddComponent<LineRenderer>();
line.positionCount = PointCount;
line.useWorldSpace = true;
line.alignment = LineAlignment.View;
line.textureMode = LineTextureMode.Stretch;
line.numCornerVertices = 8;
line.numCapVertices = 8;
line.shadowCastingMode = ShadowCastingMode.Off;
line.receiveShadows = false;
line.material = CreateMaterial();
line.widthMultiplier = width;
ApplyGradient(line, alpha);
return line;
}
private IEnumerator Animate()
{
float age = 0.0f;
while (age < Lifetime)
{
float t = age / Lifetime;
float length = Mathf.Lerp(0.72f, 1.45f, t);
float bend = Mathf.Lerp(0.12f, 0.34f, t);
float alpha = 1.0f - t;
UpdateLine(glowLine, length, bend, alpha * 0.45f);
UpdateLine(coreLine, length * 0.88f, bend * 0.55f, alpha * 0.95f);
age += Time.deltaTime;
yield return null;
}
Destroy(gameObject);
}
private void UpdateLine(LineRenderer line, float length, float bend, float alpha)
{
if (line == null)
return;
for (int i = 0; i < PointCount; i++)
{
float normalized = PointCount <= 1 ? 0.0f : (float)i / (PointCount - 1);
float offset = normalized - 0.5f;
float curve = Mathf.Sin(normalized * Mathf.PI) * bend;
line.SetPosition(i, center + tangent * (offset * length) + lift * curve);
}
ApplyGradient(line, alpha);
}
private static Material CreateMaterial()
{
Shader shader = Shader.Find("Sprites/Default");
if (shader == null)
shader = Shader.Find("Unlit/Transparent");
Material material = new Material(shader);
material.name = "Runtime Slice Trail";
return material;
}
private void ApplyGradient(LineRenderer line, float alpha)
{
Color start = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
Color mid = new Color(effectColor.r, effectColor.g, effectColor.b, alpha);
Color end = new Color(effectColor.r, effectColor.g, effectColor.b, 0.0f);
Gradient gradient = new Gradient();
gradient.SetKeys(
new[]
{
new GradientColorKey(start, 0.0f),
new GradientColorKey(mid, 0.5f),
new GradientColorKey(end, 1.0f),
},
new[]
{
new GradientAlphaKey(0.0f, 0.0f),
new GradientAlphaKey(alpha, 0.5f),
new GradientAlphaKey(0.0f, 1.0f),
});
line.colorGradient = gradient;
}
private static Color NormalizeColor(Color color)
{
float max = Mathf.Max(color.r, Mathf.Max(color.g, color.b));
if (max > 1.0f)
color /= max;
color.a = 1.0f;
return color;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 41e40f1c9d0c48af9e2ad90cb8c5108c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -56,10 +56,12 @@ namespace VRBeats
//notify to whoever is listening that the player did a correct/incorrect slice
if ( IsCutIntentValid(info as BeatDamageInfo) )
{
ScoreManager.ReportSliceTiming(GetTimingErrorSeconds());
onCorrectSlice.Invoke();
}
else
{
ScoreManager.ReportMiss();
onIncorrectSlice.Invoke();
}
@@ -98,6 +100,7 @@ namespace VRBeats
public void Kill()
{
ScoreManager.ReportMiss();
onPlayerMiss.Invoke();
canBeKilled = false;
transform.ScaleTween(Vector3.zero, 2.0f).SetEase(Ease.EaseOutExpo).SetOnComplete( delegate
@@ -107,6 +110,13 @@ namespace VRBeats
} );
}
private float GetTimingErrorSeconds()
{
float speed = Mathf.Max(Mathf.Abs(thisSpawneable.Speed), 0.001f);
float distanceFromPlayer = Mathf.Abs(transform.position.z - player.position.z);
return distanceFromPlayer / speed;
}
}
@@ -1,6 +1,8 @@
using UnityEngine;
using System.Collections;
using UnityEngine;
using Platinio;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using VRBeats.ScriptableEvents;
using VRSDK;
@@ -60,24 +62,22 @@ namespace VRBeats
Vector3 finalPosition = CalculateSpawnPosition( info.position);
Vector3 travelOffset = Vector3.forward * -settings.TargetTravelDistance;
Vector3 spawnPosition = finalPosition - travelOffset;
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
SetSpeedRelativeToPlayZone(info);
clone.Construct(info);
Vector3 finalScale = clone.transform.localScale;
clone.transform.localScale = Vector3.zero;
float travelTime = info.travelTimeOverride > 0f ? info.travelTimeOverride : settings.TargetTravelTime;
clone.transform.Move(finalPosition, travelTime).SetEase(settings.TargetTravelEase).SetOnComplete(delegate
{
info.speed = settings.TargetTravelDistance / Mathf.Max(0.05f, travelTime);
SetSpeedRelativeToPlayZone(info);
Spawneable clone = Instantiate( spawneable , spawnPosition , Quaternion.Euler( info.rotation ) );
clone.Construct(info);
StartCoroutine(BeginContinuousSpawnNextFrame(clone));
}
private IEnumerator BeginContinuousSpawnNextFrame(Spawneable clone)
{
yield return null;
if (clone != null)
clone.OnSpawn();
}).SetUpdateMode(Platinio.TweenEngine.UpdateMode.Update);
clone.transform.ScaleTween(finalScale, travelTime).SetEase(settings.TargetTravelEase);
}
private void SetSpeedRelativeToPlayZone(SpawnEventInfo info)
@@ -122,13 +122,7 @@ namespace VRBeats
public void RestartLevel()
{
gameObject.CancelAllTweens();
isGameRunning = true;
audioManager.SetAudioMixerPitch(1.0f);
enviromentController.TurnLightsOn();
playableDirector.time = 0.0f;
playableDirector.Play();
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
@@ -39,9 +39,9 @@ namespace VRBeats
public void EnableXRRayInteractorComponents()
{
if (rayInteractor != null)
rayInteractor.enabled = true;
rayInteractor.enabled = false;
if (interactorLineVisual != null)
interactorLineVisual.enabled = true;
interactorLineVisual.enabled = false;
if (lineRender != null)
lineRender.enabled = true;
+12 -1
View File
@@ -15,10 +15,14 @@ namespace VRBeats
private VR_Grabbable grabbable = null;
private ColorSide colorSide = ColorSide.Left;
private MeshRenderer[] renderArray = null;
private SaberTrailEffect trailEffect = null;
private void Awake()
{
renderArray = transform.GetComponentsInChildren<MeshRenderer>();
trailEffect = GetComponent<SaberTrailEffect>();
if (trailEffect == null)
trailEffect = gameObject.AddComponent<SaberTrailEffect>();
grabbable = GetComponent<VR_Grabbable>();
grabbable.OnGrabStateChange.AddListener(OnGrabStateChange);
@@ -44,6 +48,9 @@ namespace VRBeats
{
SetMaterialBindings(materialBindingArray[n], c);
}
if (trailEffect != null)
trailEffect.SetColor(c);
}
private void SetMaterialBindings(MaterialBindings matBindings, Color c)
@@ -55,11 +62,15 @@ namespace VRBeats
public void MakeVisible()
{
SetRenderArrayEnableValue(true);
if (trailEffect != null)
trailEffect.SetVisible(true);
}
public void MakeInvisible()
{
SetRenderArrayEnableValue(false);
if (trailEffect != null)
trailEffect.SetVisible(false);
}
private void SetRenderArrayEnableValue(bool value)
@@ -72,4 +83,4 @@ namespace VRBeats
}
}
}
@@ -13,24 +13,42 @@ namespace VRBeats
private void Start()
{
if (colorSide == ColorSide.Left) controller = VR_Manager.instance.Player.LeftController;
if (colorSide == ColorSide.Right) controller = VR_Manager.instance.Player.RightController;
ResolveController();
}
protected override DamageInfo CreateDamageInfo(Vector3 hitPoint)
{
ResolveController();
var damageInfo = base.CreateDamageInfo(hitPoint);
BeatDamageInfo beatDamageInfo = new BeatDamageInfo(damageInfo);
Vector3 controllerVelocity = controller.Velocity;
Vector3 controllerVelocity = controller != null ? controller.Velocity : Vector3.zero;
beatDamageInfo.hitForce = Mathf.Min((controllerVelocity * hitForce).magnitude, maxHitForce);
beatDamageInfo.hitObject = gameObject;
beatDamageInfo.colorSide = colorSide;
beatDamageInfo.velocity = controller.Velocity.magnitude;
beatDamageInfo.velocity = controllerVelocity.magnitude;
return beatDamageInfo;
}
private void ResolveController()
{
VR_Grabbable grabbable = GetComponent<VR_Grabbable>();
if (grabbable != null && grabbable.GrabController != null)
{
controller = grabbable.GrabController;
colorSide = controller.ControllerType == VR_ControllerType.Right ? ColorSide.Right : ColorSide.Left;
return;
}
if (VR_Manager.instance == null || VR_Manager.instance.Player == null)
return;
controller = colorSide == ColorSide.Left
? VR_Manager.instance.Player.LeftController
: VR_Manager.instance.Player.RightController;
}
}
}
@@ -32,7 +32,7 @@ namespace VRBeats
public Vector3 rotation = Vector3.zero;
public float speed = 2.0f;
public int speedMultiplier = 1;
// 0 이면 Settings.TargetTravelTime 사용, 양수면 해당 시간로 이동
// 0이면 Settings.TargetTravelTime 사용, 양수면 해당 시간 동안 일정 속도로 이동
public float travelTimeOverride = 0f;
}
}
@@ -21,21 +21,30 @@ namespace VRBeats
}
scoreManager = FindFirstObjectByType<ScoreManager>();
ApplyPopupTextStyle();
}
public void ShowScore()
{
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , scoreFadeTime).SetOnUpdateFloat(delegate (float v)
if (scoreText == null || scoreManager == null)
return;
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , Mathf.Min(scoreFadeTime, 0.8f)).SetOnUpdateFloat(delegate (float v)
{
SetScore( (int)v );
}).SetOnComplete(delegate
{
scoreText.text = scoreManager.BuildResultSummary(length);
});
}
public void ResetValues()
{
gameObject.CancelAllTweens();
scoreText.text = initialValue;
ApplyPopupTextStyle();
if (scoreText != null)
scoreText.text = initialValue;
}
@@ -56,5 +65,20 @@ namespace VRBeats
}
private void ApplyPopupTextStyle()
{
if (scoreText == null)
return;
scoreText.enableAutoSizing = false;
scoreText.fontSize = 4.4f;
scoreText.alignment = TextAlignmentOptions.Center;
scoreText.overflowMode = TextOverflowModes.Overflow;
scoreText.textWrappingMode = TextWrappingModes.NoWrap;
scoreText.lineSpacing = -18.0f;
scoreText.color = Color.white;
scoreText.richText = true;
}
}
}
+411 -96
View File
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using Platinio.TweenEngine;
using VRBeats.ScriptableEvents;
@@ -7,6 +7,14 @@ namespace VRBeats
{
public class ScoreManager : MonoBehaviour
{
private enum BeatJudgement
{
Perfect,
Great,
Good,
Miss
}
[SerializeField] private Text multiplierLabel = null;
[SerializeField] private Text scoreLabel = null;
[SerializeField] private Image multiplierLoader = null;
@@ -14,61 +22,167 @@ namespace VRBeats
[SerializeField] private CanvasGroup canvasGroup = null;
[SerializeField] private GameEvent onGameOver = null;
[Header("DJMAX Style Score")]
[SerializeField] private Text comboLabel = null;
[SerializeField] private Text accuracyLabel = null;
[SerializeField] private Text judgementLabel = null;
[SerializeField] private bool createMissingHudLabels = true;
[SerializeField] private bool applyHudPlacement = true;
[SerializeField] private Vector2 hudAnchoredPosition = new Vector2(0.0f, 1.65f);
[SerializeField] private float perfectWindow = 0.08f;
[SerializeField] private float greatWindow = 0.15f;
[SerializeField] private float goodWindow = 0.25f;
private int maxMultiplier = 0;
private int scorePerHit = 0;
private int currentScore = 0;
private int currentMultiplier = 0;
private int toNextMultiplierIncrease = 2;
private int acumulateCorrectSlices = 0;
private const int MaxCourseScore = 1000000;
private float currentMultiplier = 1.0f;
private int acumulateErrors = 0;
private int errorLimit = 0;
private int totalNoteCount = 0;
private int judgedNoteCount = 0;
private int currentCombo = 0;
private int maxCombo = 0;
private int perfectCount = 0;
private int greatCount = 0;
private int goodCount = 0;
private int missCount = 0;
private int earnedAccuracyPoints = 0;
private float visualScore = 0.0f;
private int scoreTweenID = -1;
private int loaderTweenID = -1;
private BeatJudgement lastJudgement = BeatJudgement.Perfect;
private float judgementTimer = 0.0f;
private Text progressLabel = null;
private Text rankLabel = null;
private Vector3 comboBaseScale = Vector3.one;
private float songCurrentTime = 0.0f;
private float songDuration = 0.0f;
private bool resultFinalized = false;
private bool destroyed = false;
public int CurrentScore
private static bool hasPendingSliceTiming = false;
private static float pendingSliceTiming = 0.0f;
public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f);
public float AccuracyPercent
{
get
{
return currentScore;
int denominatorNotes = totalNoteCount > 0 ? totalNoteCount : judgedNoteCount;
if (denominatorNotes <= 0)
return 100.0f;
return (float)earnedAccuracyPoints / (denominatorNotes * 1000) * 100.0f;
}
}
public string Rank
{
get
{
float accuracy = AccuracyPercent;
if (accuracy >= 98.0f) return "S+";
if (accuracy >= 95.0f) return "S";
if (accuracy >= 90.0f) return "A";
if (accuracy >= 80.0f) return "B";
if (accuracy >= 70.0f) return "C";
if (accuracy >= 60.0f) return "D";
return "F";
}
}
private void Awake()
{
maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier;
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
multiplierLoader.fillAmount = 0.0f;
}
if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
PrepareHud();
}
public static void ReportSliceTiming(float timingErrorSeconds)
{
pendingSliceTiming = timingErrorSeconds;
hasPendingSliceTiming = true;
}
public static void ReportMiss()
{
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
}
public void SetTotalNotes(int noteCount)
{
totalNoteCount = Mathf.Max(0, noteCount);
resultFinalized = false;
UpdateScoreTween();
}
public void CompleteSong()
{
if (resultFinalized)
return;
resultFinalized = true;
int missedUnjudgedNotes = Mathf.Max(0, totalNoteCount - judgedNoteCount);
if (missedUnjudgedNotes > 0)
{
missCount += missedUnjudgedNotes;
judgedNoteCount += missedUnjudgedNotes;
currentCombo = 0;
currentMultiplier = 1.0f;
}
UpdateScoreTween();
}
public void SetSongProgress(float currentTime, float duration)
{
songCurrentTime = Mathf.Max(0.0f, currentTime);
songDuration = Mathf.Max(0.0f, duration);
}
public void OnGameOver()
{
{
gameObject.CancelAllTweens();
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
}
public void OnGameRestart()
{
{
ResetThisComponent();
gameObject.CancelAllTweens();
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
}
public void ResetThisComponent()
{
currentMultiplier = 0;
currentScore = 0;
acumulateCorrectSlices = 0;
{
currentMultiplier = 1.0f;
visualScore = 0;
acumulateErrors = 0;
toNextMultiplierIncrease = 2;
judgedNoteCount = 0;
currentCombo = 0;
maxCombo = 0;
perfectCount = 0;
greatCount = 0;
goodCount = 0;
missCount = 0;
earnedAccuracyPoints = 0;
judgementTimer = 0.0f;
resultFinalized = false;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (multiplierLoader != null)
multiplierLoader.fillAmount = 0.0f;
}
private void Update()
{
UpdateUI();
@@ -79,46 +193,13 @@ namespace VRBeats
if (destroyed)
return;
acumulateErrors = 0;
acumulateCorrectSlices++;
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
BeatJudgement judgement = ConsumeJudgement();
RegisterJudgement(judgement);
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
visualScore = value;
}).ID;
acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateCorrectSlices >= toNextMultiplierIncrease)
{
IncreaseMultiplier();
}
}
private void CancelTweenById(int id)
{
if(id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed)
return;
float multiplierLoaderValue = (float)acumulateCorrectSlices / (float)toNextMultiplierIncrease;
CancelTweenById(loaderTweenID);
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).ID;
}
public void OnIncorrectSlice()
@@ -126,18 +207,63 @@ namespace VRBeats
if (destroyed)
return;
RegisterJudgement(BeatJudgement.Miss);
acumulateErrors++;
acumulateCorrectSlices = 0;
currentMultiplier = 0;
toNextMultiplierIncrease = 2;
currentMultiplier = 1.0f;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateErrors > errorLimit)
{
onGameOver.Invoke();
}
}
public string BuildResultSummary(int minScoreLength)
{
string score = CurrentScore.ToString();
score = new string('0', Mathf.Max(minScoreLength - score.Length, 0)) + score;
string badge = missCount == 0
? (greatCount == 0 && goodCount == 0 ? "PERFECT PLAY" : "FULL COMBO")
: "TRY AGAIN";
return $"<size=150%><color=#41F2FF>{Rank}</color></size>\n" +
$"<size=118%>{score}</size>\n" +
$"<size=56%><color=#D7F7FF>ACC {AccuracyPercent:0.0}% {badge}</color></size>\n" +
$"<size=50%>MAX COMBO {maxCombo}</size>\n" +
$"<size=42%><color=#A9B7C0>P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}</color></size>";
}
private void CancelTweenById(int id)
{
if (id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed || multiplierLoader == null)
return;
float multiplierLoaderValue = GetComboTierProgress();
CancelTweenById(loaderTweenID);
loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f)
.SetEase(Ease.EaseOutExpo)
.SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
})
.SetOwner(multiplierLoader.gameObject)
.ID;
}
private void UpdateScoreTween()
{
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, CurrentScore, scoreFollowTime)
.SetEase(Ease.EaseOutExpo)
.SetOnUpdateFloat(delegate (float value) { visualScore = value; })
.ID;
}
private void UpdateUI()
@@ -145,41 +271,230 @@ namespace VRBeats
if (destroyed)
return;
multiplierLabel.text = currentMultiplier.ToString();
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString();
}
if (multiplierLabel != null)
multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}";
if (scoreLabel != null)
scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}";
if (comboLabel != null)
comboLabel.text = currentCombo > 0
? $"<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>{currentCombo}</size>"
: "<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>0</size>";
if (accuracyLabel != null)
accuracyLabel.text = $"{AccuracyPercent:0.0}%";
if (rankLabel != null)
rankLabel.text = $"<color={GetRankColorHex()}>{Rank}</color>";
if (progressLabel != null)
progressLabel.text = songDuration > 0.0f
? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}"
: "";
private void IncreaseMultiplier()
{
if (destroyed)
if (judgementLabel == null)
return;
acumulateCorrectSlices = 0;
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier );
toNextMultiplierIncrease = (currentMultiplier + 1) * 2;
PlatinioTween.instance.CancelTween(multiplierLoader.gameObject);
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 1.0f, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).SetOnComplete( delegate
{
if (multiplierLabel != null)
{
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject);
}
} );
judgementTimer -= Time.deltaTime;
judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : "";
judgementLabel.color = GetJudgementColor(lastJudgement);
}
private BeatJudgement ConsumeJudgement()
{
if (!hasPendingSliceTiming)
return BeatJudgement.Perfect;
float timing = pendingSliceTiming;
hasPendingSliceTiming = false;
pendingSliceTiming = 0.0f;
if (timing <= perfectWindow) return BeatJudgement.Perfect;
if (timing <= greatWindow) return BeatJudgement.Great;
if (timing <= goodWindow) return BeatJudgement.Good;
return BeatJudgement.Good;
}
private void RegisterJudgement(BeatJudgement judgement)
{
lastJudgement = judgement;
judgementTimer = 0.45f;
judgedNoteCount++;
if (judgement == BeatJudgement.Perfect)
{
perfectCount++;
earnedAccuracyPoints += 1000;
currentCombo++;
}
else if (judgement == BeatJudgement.Great)
{
greatCount++;
earnedAccuracyPoints += 700;
currentCombo++;
}
else if (judgement == BeatJudgement.Good)
{
goodCount++;
earnedAccuracyPoints += 400;
currentCombo++;
}
else
{
missCount++;
currentCombo = 0;
}
maxCombo = Mathf.Max(maxCombo, currentCombo);
currentMultiplier = judgement == BeatJudgement.Miss
? 1.0f
: Mathf.Min(GetComboMultiplier(currentCombo), Mathf.Max(1.0f, maxMultiplier));
PulseComboLabel(judgement);
}
private static float GetComboMultiplier(int combo)
{
if (combo >= 200) return 1.5f;
if (combo >= 100) return 1.35f;
if (combo >= 50) return 1.2f;
if (combo >= 20) return 1.1f;
return 1.0f;
}
private static string GetJudgementText(BeatJudgement judgement)
{
switch (judgement)
{
case BeatJudgement.Perfect: return "PERFECT";
case BeatJudgement.Great: return "GREAT";
case BeatJudgement.Good: return "GOOD";
default: return "BREAK";
}
}
private static Color GetJudgementColor(BeatJudgement judgement)
{
switch (judgement)
{
case BeatJudgement.Perfect: return new Color(0.25f, 0.95f, 1.0f, 1.0f);
case BeatJudgement.Great: return new Color(0.58f, 1.0f, 0.45f, 1.0f);
case BeatJudgement.Good: return new Color(1.0f, 0.8f, 0.35f, 1.0f);
default: return new Color(1.0f, 0.25f, 0.45f, 1.0f);
}
}
private float GetComboTierProgress()
{
int lower = 0;
int upper = 20;
if (currentCombo >= 200) return 1.0f;
if (currentCombo >= 100) { lower = 100; upper = 200; }
else if (currentCombo >= 50) { lower = 50; upper = 100; }
else if (currentCombo >= 20) { lower = 20; upper = 50; }
return Mathf.InverseLerp(lower, upper, currentCombo);
}
private void PrepareHud()
{
RectTransform rect = transform as RectTransform;
if (applyHudPlacement && rect != null)
rect.anchoredPosition = hudAnchoredPosition;
ConfigureText(scoreLabel, new Vector2(-255.0f, -18.0f), new Vector2(220.0f, 40.0f), 26, Color.white, TextAnchor.MiddleLeft);
ConfigureText(multiplierLabel, new Vector2(255.0f, 36.0f), new Vector2(100.0f, 68.0f), 34, Color.white, TextAnchor.MiddleCenter);
ConfigureImage(multiplierLoader, new Vector2(255.0f, 36.0f), new Vector2(104.0f, 104.0f));
if (!createMissingHudLabels)
return;
comboLabel ??= CreateHudText("Combo", new Vector2(-255.0f, 74.0f), new Vector2(220.0f, 112.0f), 36, Color.white, TextAnchor.MiddleLeft);
accuracyLabel ??= CreateHudText("Accuracy", new Vector2(-255.0f, -58.0f), new Vector2(220.0f, 34.0f), 20, new Color(0.88f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleLeft);
rankLabel ??= CreateHudText("Rank", new Vector2(-255.0f, -105.0f), new Vector2(220.0f, 76.0f), 48, Color.white, TextAnchor.MiddleLeft);
judgementLabel ??= CreateHudText("Judgement", new Vector2(0.0f, 112.0f), new Vector2(260.0f, 54.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter);
progressLabel ??= CreateHudText("SongProgress", new Vector2(255.0f, -62.0f), new Vector2(180.0f, 34.0f), 18, Color.white, TextAnchor.MiddleCenter);
comboBaseScale = comboLabel != null ? comboLabel.transform.localScale : Vector3.one;
}
private Text CreateHudText(string name, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
{
GameObject textObject = new GameObject(name);
textObject.layer = gameObject.layer;
textObject.transform.SetParent(transform, false);
RectTransform rect = textObject.AddComponent<RectTransform>();
textObject.AddComponent<CanvasRenderer>();
Text text = textObject.AddComponent<Text>();
ConfigureText(text, anchoredPosition, size, fontSize, color, alignment);
return text;
}
private static void ConfigureText(Text text, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment)
{
if (text == null)
return;
RectTransform rect = text.rectTransform;
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
text.fontSize = fontSize;
text.color = color;
text.alignment = alignment;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
text.supportRichText = true;
text.lineSpacing = 0.86f;
Shadow shadow = text.GetComponent<Shadow>() ?? text.gameObject.AddComponent<Shadow>();
shadow.effectColor = new Color(0.0f, 0.0f, 0.0f, 0.72f);
shadow.effectDistance = new Vector2(3.0f, -3.0f);
}
private static void ConfigureImage(Image image, Vector2 anchoredPosition, Vector2 size)
{
if (image == null)
return;
RectTransform rect = image.rectTransform;
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
image.color = new Color(1.0f, 1.0f, 1.0f, 0.85f);
image.raycastTarget = false;
}
private string GetRankColorHex()
{
switch (Rank)
{
case "S+": return "#41F2FF";
case "S": return "#69FFD1";
case "A": return "#B9FF72";
case "B": return "#FFE06A";
case "C": return "#FFB15C";
case "D": return "#FF7C7C";
default: return "#A9B7C0";
}
}
private void PulseComboLabel(BeatJudgement judgement)
{
if (comboLabel == null || judgement == BeatJudgement.Miss)
return;
comboLabel.gameObject.CancelAllTweens();
comboLabel.transform.localScale = comboBaseScale * 1.08f;
comboLabel.transform.ScaleTween(comboBaseScale, 0.16f).SetEase(Ease.EaseOutExpo).SetOwner(comboLabel.gameObject);
}
private static string FormatTime(float seconds)
{
int wholeSeconds = Mathf.Max(0, Mathf.FloorToInt(seconds));
int minutes = wholeSeconds / 60;
int remainingSeconds = wholeSeconds % 60;
return $"{minutes}:{remainingSeconds:00}";
}
}
}
+2 -2
View File
@@ -14,9 +14,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
rightColor: {r: 0, g: 0.6002884, b: 1, a: 1}
leftColor: {r: 1, g: 0, b: 0, a: 1}
glowIntensity: 100
glowIntensity: 40
targetTravelDistance: 40
targetTravelTime: 1.8
targetTravelTime: 3.2
targetTravelEase: 19
errorLimit: 7
scorePerHit: 50
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: f47e26c36f77476ba803b5158d1b30da
VideoClipImporter:
externalObjects: {}
serializedVersion: 3
frameRange: 0
startFrame: -1
endFrame: -1
colorSpace: 0
deinterlace: 0
encodeAlpha: 0
flipVertical: 0
flipHorizontal: 0
importAudio: 1
targetSettings: {}
userData:
assetBundleName:
assetBundleVariant: