diff --git a/.gitignore b/.gitignore index 6d55965..6f65de2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,21 @@ # Credentials — never commit .env +/env +/cookies.txt /Assets/StreamingAssets/nas_config.json /Assets/StreamingAssets/nas_config.json.meta + +# Local tool output +/Captures/ +/tools/unity-mcp-server/node_modules/ +/Assets/_Recovery/ +/Assets/_Recovery.meta + +# Local video sources / superseded test clips +/Assets/img/*.mkv +/Assets/img/*.mkv.meta +/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4 +/Assets/img/No Copyright Neon Lights Modern Animated Loop Background - Free Footage - Motion Stock Footage.mp4.meta +/Assets/img/neon_background_unity.mp4 +/Assets/img/neon_background_unity.mp4.meta diff --git a/Assets/Editor/UnityCodexBridgeServer.cs b/Assets/Editor/UnityCodexBridgeServer.cs new file mode 100644 index 0000000..afc4e1e --- /dev/null +++ b/Assets/Editor/UnityCodexBridgeServer.cs @@ -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 Jobs = new ConcurrentQueue(); + private static readonly List Logs = new List(); + 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()); + 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 ParseQuery(string query) + { + Dictionary values = new Dictionary(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 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(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 query) + { + int count = GetInt(query, "count", 80, 1, MaxLogs); + List 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(); + + 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 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(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 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(); + 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 parts = new Stack(); + 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 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 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*\\{(?.*?)\\}", + 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*(?-?\\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 Query; + public readonly string Body; + + public BridgeRequest(string method, string path, Dictionary 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; + } + } +} diff --git a/Assets/Editor/UnityCodexBridgeServer.cs.meta b/Assets/Editor/UnityCodexBridgeServer.cs.meta new file mode 100644 index 0000000..d4248e7 --- /dev/null +++ b/Assets/Editor/UnityCodexBridgeServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3aaf23c3d6f42b5b1f68100b9b0f682 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/Game.unity b/Assets/Scenes/Game.unity index 27d0663..1fd9900 100644 --- a/Assets/Scenes/Game.unity +++ b/Assets/Scenes/Game.unity @@ -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} diff --git a/Assets/Scenes/SongCreator.unity b/Assets/Scenes/SongCreator.unity index 1cdb74e..7b37063 100644 --- a/Assets/Scenes/SongCreator.unity +++ b/Assets/Scenes/SongCreator.unity @@ -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 diff --git a/Assets/Script/BeatSageConverter.cs b/Assets/Script/BeatSageConverter.cs index 5629211..e3880b9 100644 --- a/Assets/Script/BeatSageConverter.cs +++ b/Assets/Script/BeatSageConverter.cs @@ -36,6 +36,8 @@ public class BeatSageNote public static class BeatSageConverter { + private static readonly bool LogConversions = false; + public static List Convert(string rawJson, float bpm) { var result = new List(); @@ -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; } diff --git a/Assets/Script/DownloadManager.cs b/Assets/Script/DownloadManager.cs index ebf8b19..d73815d 100644 --- a/Assets/Script/DownloadManager.cs +++ b/Assets/Script/DownloadManager.cs @@ -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 onProgress, Action onComplete, Action 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); + } + } } diff --git a/Assets/Script/Game360VideoBackground.cs b/Assets/Script/Game360VideoBackground.cs new file mode 100644 index 0000000..e7f3b9a --- /dev/null +++ b/Assets/Script/Game360VideoBackground.cs @@ -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.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}"); + } +} diff --git a/Assets/Script/Game360VideoBackground.cs.meta b/Assets/Script/Game360VideoBackground.cs.meta new file mode 100644 index 0000000..04394b4 --- /dev/null +++ b/Assets/Script/Game360VideoBackground.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e381cd99de84f67b9f83c19a032dc24 diff --git a/Assets/Script/GlobalSyncSettings.cs b/Assets/Script/GlobalSyncSettings.cs new file mode 100644 index 0000000..416a130 --- /dev/null +++ b/Assets/Script/GlobalSyncSettings.cs @@ -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(); + } +} diff --git a/Assets/Script/GlobalSyncSettings.cs.meta b/Assets/Script/GlobalSyncSettings.cs.meta new file mode 100644 index 0000000..bdac2c9 --- /dev/null +++ b/Assets/Script/GlobalSyncSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2e8c518ec2f4a03a6d820774b475ce0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Script/MenuSyncButtonInjector.cs b/Assets/Script/MenuSyncButtonInjector.cs new file mode 100644 index 0000000..7a012e9 --- /dev/null +++ b/Assets/Script/MenuSyncButtonInjector.cs @@ -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() != null) + return; + + GameObject go = new GameObject("[MenuSyncButtonInjector]"); + DontDestroyOnLoad(go); + go.AddComponent(); + } + + 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()) + { + loader.enabled = false; + Destroy(loader); + } + + syncButton.onClick.RemoveAllListeners(); + syncButton.onClick.AddListener(SyncCalibrationOverlay.Open); + + RectTransform sourceRect = sourceButton.GetComponent(); + RectTransform syncRect = syncButton.GetComponent(); + syncRect.anchoredPosition = sourceRect.anchoredPosition + new Vector2(0.0f, -22.0f); + + TextMeshProUGUI tmp = syncButton.GetComponentInChildren(true); + if (tmp != null) + tmp.text = "SYNC"; + + Text text = syncButton.GetComponentInChildren(true); + if (text != null) + text.text = "SYNC"; + } + + private static Button FindButtonByText(string text) + { + foreach (Button button in FindObjectsByType