feature: SceneEditPresenceTool
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0786eea16a39aad45a66d878d9427145
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<SetEditModeResponse>("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<SetEditModeResponse>("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<ReleaseResponse>("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<OpenSceneResponse>("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<HeartbeatResponse>("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<T> Request<T>(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<T>(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<UserConfig>(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<string> _onSave;
|
||||
private static AskUserIdWindow _instance;
|
||||
|
||||
private string _userId = "";
|
||||
|
||||
public static void ShowWindow(Action<string> onSave)
|
||||
{
|
||||
if (_instance != null)
|
||||
{
|
||||
_instance.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_onSave = onSave;
|
||||
|
||||
_instance = CreateInstance<AskUserIdWindow>();
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9c85ae0c5f70cc4da07f387065c0015
|
||||
Reference in New Issue
Block a user