diff --git a/Assets/Unheimlich_POC/Editor.meta b/Assets/Unheimlich_POC/Editor.meta new file mode 100644 index 00000000..d3f3357d --- /dev/null +++ b/Assets/Unheimlich_POC/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0786eea16a39aad45a66d878d9427145 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs b/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs new file mode 100644 index 00000000..19b4f77b --- /dev/null +++ b/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs @@ -0,0 +1,642 @@ +using System; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +[InitializeOnLoad] +public static class SceneEditPresenceTool +{ + private const string ServerUrl = "http://unityapi.coiriergraphics.fr/scene_presence/"; + private const double HeartbeatInterval = 10.0; + + private static string _userId; + private static string _projectName; + private static string _scenePath; + private static double _nextHeartbeat; + private static bool _offline; + private static string _currentEditor = "none"; + private static bool _requestInFlight; + + private const float OverlayHeight = 30f; + private const float OverlayMarginTop = 3f; + private const float OverlayOffsetX = 48f; + private const float OverlayBaseWidth = 420f; + private const float OverlayWidthScale = .70f; + private const float ButtonWidth = 78f; + + private static string ConfigPath => + Path.Combine( + Directory.GetParent(Application.dataPath).FullName, + "UserSettings", + "ScenePresenceUser.json" + ); + + static SceneEditPresenceTool() + { + _projectName = Directory.GetParent(Application.dataPath).Name; + _userId = LoadOrAskUserId(); + + EditorApplication.delayCall += OnStartup; + EditorApplication.update += OnUpdate; + EditorSceneManager.activeSceneChangedInEditMode += OnSceneChanged; + SceneView.duringSceneGui += OnSceneGui; + } + + private static async void OnStartup() + { + if (string.IsNullOrWhiteSpace(_userId)) + return; + + var scene = SceneManager.GetActiveScene(); + if (!string.IsNullOrEmpty(scene.path)) + { + _scenePath = scene.path; + await OpenScene(); + } + } + + private static async void OnSceneChanged(Scene oldScene, Scene newScene) + { + if (string.IsNullOrWhiteSpace(_userId)) + return; + + if (!string.IsNullOrEmpty(newScene.path)) + { + _scenePath = newScene.path; + await OpenScene(); + } + } + + private static async void OnUpdate() + { + if (string.IsNullOrWhiteSpace(_userId)) + return; + + if (string.IsNullOrEmpty(_scenePath)) + return; + + if (_requestInFlight) + return; + + if (EditorApplication.timeSinceStartup < _nextHeartbeat) + return; + + _nextHeartbeat = EditorApplication.timeSinceStartup + HeartbeatInterval; + await Heartbeat(); + RepaintSceneView(); + } + + private static void OnSceneGui(SceneView sceneView) + { + Handles.BeginGUI(); + + float width = Mathf.Min(sceneView.position.width - (OverlayOffsetX + 16f), OverlayBaseWidth) * OverlayWidthScale; + + Rect rect = new Rect( + OverlayOffsetX, + OverlayMarginTop, + width, + OverlayHeight + ); + + DrawTopBar(rect); + + Handles.EndGUI(); + } + + private static void DrawTopBar(Rect rect) + { + UiState state = GetUiState(); + string buttonLabel = state == UiState.OwnedByMe ? "Release" : "Edit"; + bool buttonEnabled = state != UiState.Offline && !_requestInFlight; + + Rect buttonRect = new Rect( + rect.xMax - ButtonWidth - 6f, + rect.y + 4f, + ButtonWidth, + rect.height - 8f + ); + + Rect textRect = new Rect( + rect.x + 10f, + rect.y, + rect.width - ButtonWidth - 22f, + rect.height + ); + + EditorGUI.DrawRect(rect, GetBackgroundColor(state)); + + GUIStyle textStyle = new GUIStyle(EditorStyles.label) + { + alignment = TextAnchor.MiddleLeft, + fontStyle = FontStyle.Bold, + fontSize = 11, + normal = { textColor = Color.white }, + clipping = TextClipping.Clip + }; + + GUI.Label(textRect, GetMessage(state), textStyle); + + Color oldColor = GUI.backgroundColor; + GUI.backgroundColor = GetButtonColor(state); + + EditorGUI.BeginDisabledGroup(!buttonEnabled); + if (GUI.Button(buttonRect, buttonLabel)) + { + if (state == UiState.MissingUserId) + { + AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh); + } + else if (state == UiState.OwnedByMe) + { + ReleaseEditMode(); + } + else + { + TakeEditMode(); + } + } + EditorGUI.EndDisabledGroup(); + + GUI.backgroundColor = oldColor; + } + + private static UiState GetUiState() + { + if (string.IsNullOrWhiteSpace(_userId)) + return UiState.MissingUserId; + + if (_offline) + return UiState.Offline; + + if (_currentEditor == _userId) + return UiState.OwnedByMe; + + if (!string.IsNullOrEmpty(_currentEditor) && _currentEditor != "none") + return UiState.OwnedByOther; + + return UiState.Free; + } + + private static string GetMessage(UiState state) + { + switch (state) + { + case UiState.MissingUserId: + return "User ID required"; + case UiState.OwnedByMe: + return "You are editing this scene"; + case UiState.OwnedByOther: + return $"{_currentEditor} is editing this scene"; + case UiState.Offline: + return "Offline"; + default: + return "Scene available"; + } + } + + private static Color GetBackgroundColor(UiState state) + { + const float alpha = 0.70f; + + switch (state) + { + case UiState.MissingUserId: + case UiState.Offline: + return new Color(1.00f, 0.10f, 0.10f, alpha); + + case UiState.OwnedByMe: + return new Color(0.22f, 0.55f, 0.28f, alpha); + + case UiState.OwnedByOther: + return new Color(0.82f, 0.48f, 0.10f, alpha); + + default: + return new Color(0.18f, 0.18f, 0.18f, alpha); + } + } + + private static Color GetButtonColor(UiState state) + { + switch (state) + { + case UiState.Free: + return new Color(0.38f, 0.66f, 1.00f, 1f); + + case UiState.MissingUserId: + case UiState.OwnedByOther: + case UiState.OwnedByMe: + case UiState.Offline: + default: + return new Color(0.22f, 0.22f, 0.22f, 1f); + } + } + + private static async void TakeEditMode() + { + if (string.IsNullOrWhiteSpace(_userId)) + { + AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh); + return; + } + + if (string.IsNullOrEmpty(_scenePath)) + return; + + EditModeRequest payload = new EditModeRequest + { + projectName = _projectName, + scenePath = _scenePath, + userId = _userId, + dateTime = DateTime.UtcNow.ToString("o"), + force = false + }; + + SetEditModeResponse result = await Request("set_edit_mode.php", payload); + if (result == null) + { + SetOffline(); + return; + } + + _offline = false; + + if (!result.success) + { + Debug.LogError("[ScenePresence] set_edit_mode failed."); + RepaintSceneView(); + return; + } + + if (result.granted) + { + _currentEditor = _userId; + RepaintSceneView(); + return; + } + + string ownerName = !string.IsNullOrWhiteSpace(result.occupiedBy) + ? result.occupiedBy + : (!string.IsNullOrWhiteSpace(_currentEditor) && _currentEditor != "none" ? _currentEditor : "another user"); + + bool forceTake = EditorUtility.DisplayDialog( + "Scene Already In Edit Mode", + $"This scene is already locked by {ownerName}.\nDo you want to take control anyway?", + "Take Control", + "Cancel" + ); + + if (!forceTake) + return; + + payload.force = true; + + SetEditModeResponse forced = await Request("set_edit_mode.php", payload); + if (forced == null) + { + SetOffline(); + return; + } + + _offline = false; + + if (forced.success && forced.granted) + { + _currentEditor = _userId; + RepaintSceneView(); + } + } + + private static async void ReleaseEditMode() + { + if (string.IsNullOrWhiteSpace(_userId)) + return; + + if (string.IsNullOrEmpty(_scenePath)) + return; + + ReleaseResponse result = await Request("release_edit_mode.php", new BaseRequest + { + projectName = _projectName, + scenePath = _scenePath, + userId = _userId, + dateTime = DateTime.UtcNow.ToString("o") + }); + + if (result == null) + { + SetOffline(); + return; + } + + _offline = false; + _currentEditor = "none"; + RepaintSceneView(); + await OpenScene(); + } + + private static async Task OpenScene() + { + OpenSceneResponse result = await Request("open_scene.php", new BaseRequest + { + projectName = _projectName, + scenePath = _scenePath, + userId = _userId, + dateTime = DateTime.UtcNow.ToString("o") + }); + + if (result == null) + { + SetOffline(); + return; + } + + _offline = false; + + if (result.hasEditor) + { + _currentEditor = string.IsNullOrWhiteSpace(result.editorUserId) ? "none" : result.editorUserId; + } + else + { + _currentEditor = "none"; + } + + RepaintSceneView(); + } + + private static async Task Heartbeat() + { + HeartbeatResponse result = await Request("heartbeat.php", new BaseRequest + { + projectName = _projectName, + scenePath = _scenePath, + userId = _userId, + dateTime = DateTime.UtcNow.ToString("o") + }); + + if (result == null) + { + SetOffline(); + return; + } + + _offline = false; + + if (result.hasEditor) + _currentEditor = string.IsNullOrWhiteSpace(result.editorUserId) ? "none" : result.editorUserId; + else + _currentEditor = "none"; + + RepaintSceneView(); + } + + private static async Task Request(string endpoint, object payload) where T : class + { + _requestInFlight = true; + + try + { + string json = JsonUtility.ToJson(payload); + byte[] bytes = Encoding.UTF8.GetBytes(json); + + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(ServerUrl + endpoint); + request.Method = "POST"; + request.ContentType = "application/json"; + request.Timeout = 5000; + request.ReadWriteTimeout = 5000; + request.ContentLength = bytes.Length; + + using (var requestStream = await request.GetRequestStreamAsync()) + { + await requestStream.WriteAsync(bytes, 0, bytes.Length); + } + + using var response = await request.GetResponseAsync(); + using var responseStream = response.GetResponseStream(); + using var reader = new StreamReader(responseStream); + string responseJson = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(responseJson)) + return null; + + if (!responseJson.TrimStart().StartsWith("{")) + { + Debug.LogError($"[ScenePresence] Non-JSON response from {endpoint}: {responseJson}"); + return null; + } + + //Debug.Log($"[ScenePresence] {endpoint} => {responseJson}"); + return JsonUtility.FromJson(responseJson); + } + catch (Exception ex) + { + Debug.LogError($"[ScenePresence] Request failed ({endpoint}): {ex}"); + return null; + } + finally + { + _requestInFlight = false; + } + } + + private static void SetOffline() + { + _offline = true; + RepaintSceneView(); + } + + private static void RepaintSceneView() + { + if (SceneView.lastActiveSceneView != null) + SceneView.lastActiveSceneView.Repaint(); + } + + private static string LoadOrAskUserId() + { + try + { + if (File.Exists(ConfigPath)) + { + UserConfig saved = JsonUtility.FromJson(File.ReadAllText(ConfigPath)); + if (saved != null && !string.IsNullOrWhiteSpace(saved.userId)) + return saved.userId.Trim(); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[ScenePresence] Failed to read config: {ex.Message}"); + } + + EditorApplication.delayCall += () => + { + AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh); + }; + return null; + } + + private static void SaveUserIdAndRefresh(string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + return; + + _userId = userId.Trim(); + SaveUserId(_userId); + + EditorApplication.delayCall += async () => + { + Scene scene = SceneManager.GetActiveScene(); + if (!string.IsNullOrEmpty(scene.path)) + { + _scenePath = scene.path; + await OpenScene(); + } + + RepaintSceneView(); + }; + } + + private static void SaveUserId(string userId) + { + try + { + string directory = Path.GetDirectoryName(ConfigPath); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + UserConfig config = new UserConfig { userId = userId }; + File.WriteAllText(ConfigPath, JsonUtility.ToJson(config, true)); + } + catch (Exception ex) + { + Debug.LogError($"[ScenePresence] Failed to save config: {ex.Message}"); + } + } + + [Serializable] + private class UserConfig + { + public string userId; + } + + [Serializable] + private class BaseRequest + { + public string projectName; + public string scenePath; + public string userId; + public string dateTime; + } + + [Serializable] + private class EditModeRequest : BaseRequest + { + public bool force; + } + + [Serializable] + private class OpenSceneResponse + { + public bool success; + public bool hasEditor; + public string editorUserId; + public string editorDateTime; + public string editorServerTakenAt; + public bool editorOnline; + } + + [Serializable] + private class HeartbeatResponse + { + public bool success; + public bool hasEditor; + public string editorUserId; + public bool editorOnline; + } + + [Serializable] + private class SetEditModeResponse + { + public bool success; + public bool granted; + public bool forced; + public string occupiedBy; + public string editorUserId; + } + + [Serializable] + private class ReleaseResponse + { + public bool success; + public bool released; + } + + private enum UiState + { + MissingUserId, + Free, + OwnedByOther, + OwnedByMe, + Offline + } +} + +public class AskUserIdWindow : EditorWindow +{ + private static Action _onSave; + private static AskUserIdWindow _instance; + + private string _userId = ""; + + public static void ShowWindow(Action onSave) + { + if (_instance != null) + { + _instance.Focus(); + return; + } + + _onSave = onSave; + + _instance = CreateInstance(); + _instance.titleContent = new GUIContent("Scene Presence Setup"); + _instance.minSize = new Vector2(240, 60); + _instance.maxSize = new Vector2(240, 60); + _instance.ShowUtility(); + _instance.Focus(); + } + + private void OnDestroy() + { + if (_instance == this) + _instance = null; + } + + private void OnGUI() + { + GUILayout.Space(10); + EditorGUIUtility.labelWidth = 60; + _userId = EditorGUILayout.TextField("User ID", _userId); + GUILayout.FlexibleSpace(); + + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + + GUI.enabled = !string.IsNullOrWhiteSpace(_userId); + if (GUILayout.Button("Save", GUILayout.Width(80))) + { + _onSave?.Invoke(_userId.Trim()); + Close(); + } + GUI.enabled = true; + } + + GUILayout.Space(10); + } +} \ No newline at end of file diff --git a/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs.meta b/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs.meta new file mode 100644 index 00000000..e9b69fa5 --- /dev/null +++ b/Assets/Unheimlich_POC/Editor/SceneEditPresenceTool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d9c85ae0c5f70cc4da07f387065c0015 \ No newline at end of file