Initial commit
This commit is contained in:
8
skills/assets/unity-package/Editor.meta
Normal file
8
skills/assets/unity-package/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: deb67eda7d4bc4d4e85a0ae423f65903
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
skills/assets/unity-package/Editor/Attributes.meta
Normal file
8
skills/assets/unity-package/Editor/Attributes.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37d6465efcdcc9349bf098e0b33cfffc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Attributes
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a static method as executable via CLI
|
||||
/// Only methods with this attribute can be executed through Editor.Execute command
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
|
||||
public class ExecutableMethodAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI command name (e.g., "reinstall-cli")
|
||||
/// </summary>
|
||||
public string CommandName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this method does
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Mark a method as executable via CLI
|
||||
/// </summary>
|
||||
/// <param name="commandName">CLI command name (kebab-case recommended)</param>
|
||||
/// <param name="description">Human-readable description</param>
|
||||
public ExecutableMethodAttribute(string commandName, string description = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
{
|
||||
throw new ArgumentException("Command name cannot be null or empty", nameof(commandName));
|
||||
}
|
||||
|
||||
CommandName = commandName;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f459669cdec8d5b469285409d8ea3467
|
||||
8
skills/assets/unity-package/Editor/Database.meta
Normal file
8
skills/assets/unity-package/Editor/Database.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d252d66841b3354c9e5d3a0b149edbc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9809e375b6992d4fab11953cd09b301
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Pattern 기본 추상 클래스
|
||||
/// 공통 기능 구현
|
||||
/// </summary>
|
||||
public abstract class CommandBase : ICommand
|
||||
{
|
||||
#region ICommand Properties
|
||||
public string CommandId { get; protected set; }
|
||||
public string CommandName { get; protected set; }
|
||||
public DateTime ExecutedAt { get; protected set; }
|
||||
public virtual bool CanPersist => true;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
protected CommandBase(string commandName)
|
||||
{
|
||||
CommandId = Guid.NewGuid().ToString();
|
||||
CommandName = commandName;
|
||||
ExecutedAt = DateTime.UtcNow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Abstract Methods
|
||||
/// <summary>
|
||||
/// 실제 실행 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnExecuteAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 실제 Undo 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnUndoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 실제 Redo 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnRedoAsync();
|
||||
#endregion
|
||||
|
||||
#region ICommand Implementation
|
||||
public async UniTask<bool> ExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Executing: {CommandName}");
|
||||
ExecutedAt = DateTime.UtcNow;
|
||||
bool result = await OnExecuteAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Executed successfully: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Execution failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during execution: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<bool> UndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Undoing: {CommandName}");
|
||||
bool result = await OnUndoAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Undo successful: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Undo failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during undo: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<bool> RedoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Redoing: {CommandName}");
|
||||
bool result = await OnRedoAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Redo successful: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Redo failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during redo: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public virtual string Serialize()
|
||||
{
|
||||
// 기본 직렬화 (파생 클래스에서 오버라이드 가능)
|
||||
return JsonUtility.ToJson(new CommandData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
public virtual void Deserialize(string json)
|
||||
{
|
||||
// 기본 역직렬화 (파생 클래스에서 오버라이드 가능)
|
||||
var data = JsonUtility.FromJson<CommandData>(json);
|
||||
CommandId = data.commandId;
|
||||
CommandName = data.commandName;
|
||||
ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization Data
|
||||
[Serializable]
|
||||
protected class CommandData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4cc7fda14659f4345be98770ea61234e
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Factory
|
||||
/// 데이터베이스에서 로드한 명령을 적절한 Command 인스턴스로 복원
|
||||
/// </summary>
|
||||
public static class CommandFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// command_type과 command_data로부터 ICommand 인스턴스 생성
|
||||
/// </summary>
|
||||
/// <param name="commandType">Command 타입 이름 (예: "CreateGameObjectCommand")</param>
|
||||
/// <param name="commandData">직렬화된 JSON 데이터</param>
|
||||
/// <returns>복원된 ICommand 인스턴스, 실패 시 null</returns>
|
||||
public static ICommand CreateFromDatabase(string commandType, string commandData)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (commandType)
|
||||
{
|
||||
case "CreateGameObjectCommand":
|
||||
return CreateGameObjectCommand.FromJson(commandData);
|
||||
|
||||
case "TransformChangeCommand":
|
||||
return TransformChangeCommand.FromJson(commandData);
|
||||
|
||||
// DeleteGameObjectCommand는 CanPersist = false이므로 데이터베이스에 저장되지 않음
|
||||
|
||||
default:
|
||||
ToolkitLogger.LogWarning("CommandFactory", $" 알 수 없는 Command 타입: {commandType}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandFactory", $" Command 복원 실패 - Type: {commandType}, Error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command 타입이 지원되는지 확인
|
||||
/// </summary>
|
||||
public static bool IsSupported(string commandType)
|
||||
{
|
||||
return commandType switch
|
||||
{
|
||||
"CreateGameObjectCommand" => true,
|
||||
"TransformChangeCommand" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d6bb958df2f6a242a02c7274b376c67
|
||||
@@ -0,0 +1,514 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command History 관리자
|
||||
/// Undo/Redo 스택 관리 및 세션 간 영속성
|
||||
/// </summary>
|
||||
public class CommandHistory
|
||||
{
|
||||
#region Fields
|
||||
private readonly Stack<ICommand> undoStack;
|
||||
private readonly Stack<ICommand> redoStack;
|
||||
private readonly DatabaseManager databaseManager;
|
||||
|
||||
private const int MaxHistorySize = 100; // 최대 100개 명령 기록
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// Undo 가능한 명령 개수
|
||||
/// </summary>
|
||||
public int UndoCount => undoStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Redo 가능한 명령 개수
|
||||
/// </summary>
|
||||
public int RedoCount => redoStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Undo 가능 여부
|
||||
/// </summary>
|
||||
public bool CanUndo => undoStack.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Redo 가능 여부
|
||||
/// </summary>
|
||||
public bool CanRedo => redoStack.Count > 0;
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>
|
||||
/// History 변경 이벤트 (UI 업데이트용)
|
||||
/// </summary>
|
||||
public event Action OnHistoryChanged;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public CommandHistory(DatabaseManager databaseManager)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
undoStack = new Stack<ICommand>();
|
||||
redoStack = new Stack<ICommand>();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", "생성 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute Command
|
||||
/// <summary>
|
||||
/// 명령 실행 및 히스토리 추가
|
||||
/// </summary>
|
||||
public async UniTask<bool> ExecuteCommandAsync(ICommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
// 명령 실행
|
||||
bool success = await command.ExecuteAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
|
||||
// Redo 스택 초기화 (새로운 명령이 실행되면 Redo 불가)
|
||||
redoStack.Clear();
|
||||
|
||||
// 히스토리 크기 제한
|
||||
TrimHistory();
|
||||
|
||||
// 데이터베이스에 저장 (선택적)
|
||||
if (command.CanPersist && databaseManager.IsConnected)
|
||||
{
|
||||
await PersistCommandAsync(command);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 실행 및 추가: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이미 실행된 명령을 히스토리에 기록 (실행 없이 기록만)
|
||||
/// </summary>
|
||||
public async UniTask RecordCommandAsync(ICommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
// Undo 스택에 추가 (이미 실행됨)
|
||||
undoStack.Push(command);
|
||||
|
||||
// Redo 스택 초기화 (새로운 명령이 기록되면 Redo 불가)
|
||||
redoStack.Clear();
|
||||
|
||||
// 히스토리 크기 제한
|
||||
TrimHistory();
|
||||
|
||||
// 데이터베이스에 저장 (선택적)
|
||||
if (command.CanPersist && databaseManager.IsConnected)
|
||||
{
|
||||
await PersistCommandAsync(command);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 기록: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Undo/Redo
|
||||
/// <summary>
|
||||
/// Undo 실행
|
||||
/// </summary>
|
||||
public async UniTask<bool> UndoAsync()
|
||||
{
|
||||
if (!CanUndo)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "Undo 불가능 - 스택이 비어있습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = undoStack.Pop();
|
||||
bool success = await command.UndoAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Redo 스택에 추가
|
||||
redoStack.Push(command);
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Undo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 실패 시 다시 Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
ToolkitLogger.LogError("CommandHistory", $" Undo 실패: {command.CommandName}");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo 실행 (동기 - WebSocket 핸들러용)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
|
||||
/// 내부적으로 UndoAsync()를 동기 호출합니다.
|
||||
/// </remarks>
|
||||
public bool Undo()
|
||||
{
|
||||
return UndoAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 실행
|
||||
/// </summary>
|
||||
public async UniTask<bool> RedoAsync()
|
||||
{
|
||||
if (!CanRedo)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "Redo 불가능 - 스택이 비어있습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = redoStack.Pop();
|
||||
bool success = await command.RedoAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Redo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 실패 시 다시 Redo 스택에 추가
|
||||
redoStack.Push(command);
|
||||
ToolkitLogger.LogError("CommandHistory", $" Redo 실패: {command.CommandName}");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 실행 (동기 - WebSocket 핸들러용)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
|
||||
/// 내부적으로 RedoAsync()를 동기 호출합니다.
|
||||
/// </remarks>
|
||||
public bool Redo()
|
||||
{
|
||||
return RedoAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 Undo할 명령 확인 (스택에서 제거 안함)
|
||||
/// </summary>
|
||||
public ICommand PeekUndo()
|
||||
{
|
||||
return CanUndo ? undoStack.Peek() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 Redo할 명령 확인 (스택에서 제거 안함)
|
||||
/// </summary>
|
||||
public ICommand PeekRedo()
|
||||
{
|
||||
return CanRedo ? redoStack.Peek() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo 스택 목록 가져오기
|
||||
/// </summary>
|
||||
public List<ICommand> GetUndoStack(int limit = 10)
|
||||
{
|
||||
var result = new List<ICommand>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
int count = 0;
|
||||
while (undoStack.Count > 0 && count < limit)
|
||||
{
|
||||
var cmd = undoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
result.Add(cmd);
|
||||
count++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
undoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 스택 목록 가져오기
|
||||
/// </summary>
|
||||
public List<ICommand> GetRedoStack(int limit = 10)
|
||||
{
|
||||
var result = new List<ICommand>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
int count = 0;
|
||||
while (redoStack.Count > 0 && count < limit)
|
||||
{
|
||||
var cmd = redoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
result.Add(cmd);
|
||||
count++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
redoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region History Management
|
||||
/// <summary>
|
||||
/// 전체 히스토리 초기화
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
undoStack.Clear();
|
||||
redoStack.Clear();
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", "히스토리 초기화 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 히스토리 크기 제한
|
||||
/// </summary>
|
||||
private void TrimHistory()
|
||||
{
|
||||
if (undoStack.Count > MaxHistorySize)
|
||||
{
|
||||
// 성능 개선: 리스트로 변환 후 트림하고 스택 재구성
|
||||
var list = undoStack.ToList();
|
||||
list.RemoveRange(0, list.Count - MaxHistorySize);
|
||||
|
||||
undoStack.Clear();
|
||||
for (int i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
undoStack.Push(list[i]);
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 크기 제한 적용: {undoStack.Count}개 유지");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최근 명령 목록 가져오기 (UI 표시용)
|
||||
/// </summary>
|
||||
public List<string> GetRecentCommands(int count = 10)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
// Undo 스택에서 가져오기
|
||||
int retrievedCount = 0;
|
||||
while (undoStack.Count > 0 && retrievedCount < count)
|
||||
{
|
||||
var cmd = undoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
commands.Add($"{cmd.ExecutedAt:HH:mm:ss} - {cmd.CommandName}");
|
||||
retrievedCount++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
undoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Database Persistence
|
||||
/// <summary>
|
||||
/// 명령을 데이터베이스에 저장
|
||||
/// </summary>
|
||||
private async UniTask PersistCommandAsync(ICommand command)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!databaseManager.IsConnected || databaseManager.Connector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON 직렬화
|
||||
string json = command.Serialize();
|
||||
|
||||
// SQL INSERT (SQLite 문법)
|
||||
string sql = @"
|
||||
INSERT INTO command_history (
|
||||
command_id, command_name, command_type,
|
||||
command_data, executed_at, executed_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?);";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
connection.Execute(sql,
|
||||
command.CommandId,
|
||||
command.CommandName,
|
||||
command.GetType().Name,
|
||||
json,
|
||||
command.ExecutedAt.ToString("o"), // ISO 8601 format
|
||||
"EditorUI" // 실행 주체 구분
|
||||
);
|
||||
});
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 저장 완료: {command.CommandName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandHistory", $" 명령 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스에서 히스토리 로드 (세션 복원)
|
||||
/// </summary>
|
||||
public async UniTask<int> LoadHistoryFromDatabaseAsync(DateTime since)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!databaseManager.IsConnected || databaseManager.Connector == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "데이터베이스 연결되지 않음 - 히스토리 로드 불가.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// SQL SELECT (SQLite 문법)
|
||||
string sql = @"
|
||||
SELECT command_id, command_name, command_type, command_data, executed_at
|
||||
FROM command_history
|
||||
WHERE executed_at >= ?
|
||||
ORDER BY executed_at ASC
|
||||
LIMIT 100";
|
||||
|
||||
int loadedCount = 0;
|
||||
|
||||
// DB 쿼리는 백그라운드 스레드에서 실행
|
||||
List<CommandHistoryRecord> records = null;
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
records = connection.Query<CommandHistoryRecord>(sql, since.ToString("o")).ToList();
|
||||
});
|
||||
|
||||
// Command 복원은 메인 스레드에서 실행 (Unity API 호출 가능)
|
||||
await UniTask.SwitchToMainThread();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
// CommandFactory를 사용하여 Command 복원
|
||||
var command = CommandFactory.CreateFromDatabase(record.command_type, record.command_data);
|
||||
|
||||
if (command != null)
|
||||
{
|
||||
// Undo 스택에 추가 (실행 완료된 명령)
|
||||
undoStack.Push(command);
|
||||
loadedCount++;
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Command 복원: {command.CommandName} (Type: {record.command_type})");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", $" Command 복원 실패 - Type: {record.command_type}, ID: {record.command_id}");
|
||||
}
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 로드 완료: {loadedCount}개");
|
||||
return loadedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandHistory", $" 히스토리 로드 실패: {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command History 레코드 (SQLite 쿼리 결과용)
|
||||
/// </summary>
|
||||
private class CommandHistoryRecord
|
||||
{
|
||||
public string command_id { get; set; }
|
||||
public string command_name { get; set; }
|
||||
public string command_type { get; set; }
|
||||
public string command_data { get; set; }
|
||||
public string executed_at { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Status
|
||||
/// <summary>
|
||||
/// 히스토리 상태 정보
|
||||
/// </summary>
|
||||
public HistoryStatus GetStatus()
|
||||
{
|
||||
return new HistoryStatus
|
||||
{
|
||||
UndoCount = UndoCount,
|
||||
RedoCount = RedoCount,
|
||||
CanUndo = CanUndo,
|
||||
CanRedo = CanRedo,
|
||||
MaxHistorySize = MaxHistorySize
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Status Struct
|
||||
public struct HistoryStatus
|
||||
{
|
||||
public int UndoCount;
|
||||
public int RedoCount;
|
||||
public bool CanUndo;
|
||||
public bool CanRedo;
|
||||
public int MaxHistorySize;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[HistoryStatus] Undo: {UndoCount}, Redo: {RedoCount}, Max: {MaxHistorySize}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b79709016c210a441ad295d149fe3734
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject 생성 명령
|
||||
/// </summary>
|
||||
public class CreateGameObjectCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly string gameObjectName;
|
||||
private readonly Vector3 position;
|
||||
private readonly Quaternion rotation;
|
||||
private readonly int parentInstanceId;
|
||||
private int createdInstanceId;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public CreateGameObjectCommand(
|
||||
string gameObjectName,
|
||||
Vector3 position = default,
|
||||
Quaternion rotation = default,
|
||||
GameObject parent = null)
|
||||
: base($"Create GameObject: {gameObjectName}")
|
||||
{
|
||||
this.gameObjectName = gameObjectName;
|
||||
this.position = position;
|
||||
this.rotation = rotation != default ? rotation : Quaternion.identity;
|
||||
parentInstanceId = parent != null ? parent.GetInstanceID() : 0;
|
||||
createdInstanceId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이미 생성된 GameObject로부터 Command 생성 (중복 생성 방지)
|
||||
/// </summary>
|
||||
public static CreateGameObjectCommand CreateFromExisting(GameObject existingObject, GameObject parent = null)
|
||||
{
|
||||
if (existingObject == null)
|
||||
throw new ArgumentNullException(nameof(existingObject));
|
||||
|
||||
var command = new CreateGameObjectCommand(
|
||||
existingObject.name,
|
||||
existingObject.transform.position,
|
||||
existingObject.transform.rotation,
|
||||
parent
|
||||
);
|
||||
|
||||
// 이미 생성된 객체의 InstanceID 저장
|
||||
command.createdInstanceId = existingObject.GetInstanceID();
|
||||
|
||||
return command;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// GameObject 생성
|
||||
var go = new GameObject(gameObjectName);
|
||||
go.transform.position = position;
|
||||
go.transform.rotation = rotation;
|
||||
|
||||
// 부모 설정
|
||||
if (parentInstanceId != 0)
|
||||
{
|
||||
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
|
||||
if (parent != null)
|
||||
{
|
||||
go.transform.SetParent(parent.transform, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 생성된 GameObject ID 저장
|
||||
createdInstanceId = go.GetInstanceID();
|
||||
|
||||
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 생성: {gameObjectName} (ID: {createdInstanceId})");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CreateGameObjectCommand", $" 생성 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 생성된 GameObject 삭제
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(createdInstanceId) as GameObject;
|
||||
if (go != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(go);
|
||||
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 삭제 (Undo): {gameObjectName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("CreateGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
|
||||
}
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CreateGameObjectCommand", $" Undo 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new CreateGameObjectData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectName = gameObjectName,
|
||||
position = position,
|
||||
rotation = rotation,
|
||||
parentInstanceId = parentInstanceId,
|
||||
createdInstanceId = createdInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 Command 복원 (세션 영속성용)
|
||||
/// </summary>
|
||||
public static CreateGameObjectCommand FromJson(string json)
|
||||
{
|
||||
var data = JsonUtility.FromJson<CreateGameObjectData>(json);
|
||||
|
||||
// 부모 GameObject 찾기
|
||||
GameObject parent = null;
|
||||
if (data.parentInstanceId != 0)
|
||||
{
|
||||
parent = UnityEditor.EditorUtility.InstanceIDToObject(data.parentInstanceId) as GameObject;
|
||||
}
|
||||
|
||||
// 새 인스턴스 생성
|
||||
var command = new CreateGameObjectCommand(
|
||||
data.gameObjectName,
|
||||
data.position,
|
||||
data.rotation,
|
||||
parent
|
||||
);
|
||||
|
||||
// 메타데이터 복원
|
||||
command.CommandId = data.commandId;
|
||||
command.CommandName = data.commandName;
|
||||
command.ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
command.createdInstanceId = data.createdInstanceId;
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CreateGameObjectData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public string gameObjectName;
|
||||
public Vector3 position;
|
||||
public Quaternion rotation;
|
||||
public int parentInstanceId;
|
||||
public int createdInstanceId;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b61e4d05723fa33478874aad39fed96d
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject 삭제 명령
|
||||
/// 삭제 전 상태를 저장하여 Undo 지원
|
||||
/// </summary>
|
||||
public class DeleteGameObjectCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly int gameObjectInstanceId;
|
||||
private readonly string gameObjectName;
|
||||
private readonly Vector3 position;
|
||||
private readonly Quaternion rotation;
|
||||
private readonly Vector3 scale;
|
||||
private readonly int parentInstanceId;
|
||||
private readonly int siblingIndex;
|
||||
private GameObject deletedGameObject; // Undo용 참조 보관
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public DeleteGameObjectCommand(GameObject gameObject)
|
||||
: base($"Delete GameObject: {gameObject.name}")
|
||||
{
|
||||
gameObjectInstanceId = gameObject.GetInstanceID();
|
||||
gameObjectName = gameObject.name;
|
||||
position = gameObject.transform.position;
|
||||
rotation = gameObject.transform.rotation;
|
||||
scale = gameObject.transform.localScale;
|
||||
parentInstanceId = gameObject.transform.parent != null
|
||||
? gameObject.transform.parent.gameObject.GetInstanceID()
|
||||
: 0;
|
||||
siblingIndex = gameObject.transform.GetSiblingIndex();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Undo를 위해 참조 보관 (비활성화)
|
||||
deletedGameObject = go;
|
||||
go.SetActive(false);
|
||||
go.hideFlags = HideFlags.HideInHierarchy;
|
||||
|
||||
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 삭제: {gameObjectName}");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DeleteGameObjectCommand", $" 삭제 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (deletedGameObject == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" 복원할 GameObject 참조가 없음: {gameObjectName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GameObject 복원
|
||||
deletedGameObject.SetActive(true);
|
||||
deletedGameObject.hideFlags = HideFlags.None;
|
||||
|
||||
// 부모 및 위치 복원
|
||||
if (parentInstanceId != 0)
|
||||
{
|
||||
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
|
||||
if (parent != null)
|
||||
{
|
||||
deletedGameObject.transform.SetParent(parent.transform, false);
|
||||
deletedGameObject.transform.SetSiblingIndex(siblingIndex);
|
||||
}
|
||||
}
|
||||
|
||||
deletedGameObject.transform.position = position;
|
||||
deletedGameObject.transform.rotation = rotation;
|
||||
deletedGameObject.transform.localScale = scale;
|
||||
|
||||
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 복원 (Undo): {gameObjectName}");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DeleteGameObjectCommand", $" Undo 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Persistence Override
|
||||
public override bool CanPersist => false; // GameObject 참조를 포함하므로 DB 저장 불가
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new DeleteGameObjectData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectInstanceId = gameObjectInstanceId,
|
||||
gameObjectName = gameObjectName,
|
||||
position = position,
|
||||
rotation = rotation,
|
||||
scale = scale,
|
||||
parentInstanceId = parentInstanceId,
|
||||
siblingIndex = siblingIndex
|
||||
});
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class DeleteGameObjectData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public int gameObjectInstanceId;
|
||||
public string gameObjectName;
|
||||
public Vector3 position;
|
||||
public Quaternion rotation;
|
||||
public Vector3 scale;
|
||||
public int parentInstanceId;
|
||||
public int siblingIndex;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96e777a7e70d2f245be762670a226d75
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Pattern 인터페이스
|
||||
/// 모든 실행 가능한 명령은 이 인터페이스를 구현해야 함
|
||||
/// </summary>
|
||||
public interface ICommand
|
||||
{
|
||||
/// <summary>
|
||||
/// 명령 고유 ID (데이터베이스 저장용)
|
||||
/// </summary>
|
||||
string CommandId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 이름 (UI 표시용)
|
||||
/// </summary>
|
||||
string CommandName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행 시간
|
||||
/// </summary>
|
||||
DateTime ExecutedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행
|
||||
/// </summary>
|
||||
UniTask<bool> ExecuteAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행 취소 (Undo)
|
||||
/// </summary>
|
||||
UniTask<bool> UndoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령 재실행 (Redo)
|
||||
/// </summary>
|
||||
UniTask<bool> RedoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령을 데이터베이스에 저장할 수 있는지 여부
|
||||
/// </summary>
|
||||
bool CanPersist { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령을 JSON으로 직렬화
|
||||
/// </summary>
|
||||
string Serialize();
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 명령 복원
|
||||
/// </summary>
|
||||
void Deserialize(string json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54a0273f9b663c14cb5651614952b46f
|
||||
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject Transform 변경 명령
|
||||
/// Position, Rotation, Scale 변경을 추적하고 Undo/Redo 지원
|
||||
/// </summary>
|
||||
public class TransformChangeCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly int gameObjectInstanceId;
|
||||
private readonly Vector3 oldPosition;
|
||||
private readonly Quaternion oldRotation;
|
||||
private readonly Vector3 oldScale;
|
||||
private readonly Vector3 newPosition;
|
||||
private readonly Quaternion newRotation;
|
||||
private readonly Vector3 newScale;
|
||||
private readonly string gameObjectName;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public TransformChangeCommand(
|
||||
GameObject gameObject,
|
||||
Vector3 oldPosition, Quaternion oldRotation, Vector3 oldScale,
|
||||
Vector3 newPosition, Quaternion newRotation, Vector3 newScale)
|
||||
: base($"Transform Change: {gameObject.name}")
|
||||
{
|
||||
gameObjectInstanceId = gameObject.GetInstanceID();
|
||||
gameObjectName = gameObject.name;
|
||||
|
||||
this.oldPosition = oldPosition;
|
||||
this.oldRotation = oldRotation;
|
||||
this.oldScale = oldScale;
|
||||
|
||||
this.newPosition = newPosition;
|
||||
this.newRotation = newRotation;
|
||||
this.newScale = newScale;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
var go = GetGameObject();
|
||||
if (go == null) return false;
|
||||
|
||||
// 새로운 Transform 적용
|
||||
go.transform.position = newPosition;
|
||||
go.transform.rotation = newRotation;
|
||||
go.transform.localScale = newScale;
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
var go = GetGameObject();
|
||||
if (go == null) return false;
|
||||
|
||||
// 이전 Transform 복원
|
||||
go.transform.position = oldPosition;
|
||||
go.transform.rotation = oldRotation;
|
||||
go.transform.localScale = oldScale;
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private GameObject GetGameObject()
|
||||
{
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found: {gameObjectName} (ID: {gameObjectInstanceId})");
|
||||
}
|
||||
return go;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new TransformChangeData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectInstanceId = gameObjectInstanceId,
|
||||
gameObjectName = gameObjectName,
|
||||
oldPosition = oldPosition,
|
||||
oldRotation = oldRotation,
|
||||
oldScale = oldScale,
|
||||
newPosition = newPosition,
|
||||
newRotation = newRotation,
|
||||
newScale = newScale
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 Command 복원 (세션 영속성용)
|
||||
/// </summary>
|
||||
public static TransformChangeCommand FromJson(string json)
|
||||
{
|
||||
var data = JsonUtility.FromJson<TransformChangeData>(json);
|
||||
|
||||
// GameObject 찾기
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(data.gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found (FromJson): {data.gameObjectName} (ID: {data.gameObjectInstanceId})");
|
||||
// GameObject가 없어도 Command는 생성 (히스토리 기록용)
|
||||
go = new GameObject(data.gameObjectName);
|
||||
}
|
||||
|
||||
// 새 인스턴스 생성
|
||||
var command = new TransformChangeCommand(
|
||||
go,
|
||||
data.oldPosition, data.oldRotation, data.oldScale,
|
||||
data.newPosition, data.newRotation, data.newScale
|
||||
);
|
||||
|
||||
// 메타데이터 복원
|
||||
command.CommandId = data.commandId;
|
||||
command.CommandName = data.commandName;
|
||||
command.ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class TransformChangeData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public int gameObjectInstanceId;
|
||||
public string gameObjectName;
|
||||
public Vector3 oldPosition;
|
||||
public Quaternion oldRotation;
|
||||
public Vector3 oldScale;
|
||||
public Vector3 newPosition;
|
||||
public Quaternion newRotation;
|
||||
public Vector3 newScale;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a0112613fb1ea24bb6632ee8960a2f2
|
||||
347
skills/assets/unity-package/Editor/Database/DatabaseConfig.cs
Normal file
347
skills/assets/unity-package/Editor/Database/DatabaseConfig.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 연결 설정
|
||||
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
|
||||
/// EditorPrefs에 개별 키로 저장/로드
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class DatabaseConfig
|
||||
{
|
||||
#region EditorPrefs Keys
|
||||
/// <summary>
|
||||
/// EditorPrefs 키 상수
|
||||
/// </summary>
|
||||
private const string PREF_KEY_CONFIG_VERSION = "UnityEditorToolkit.Database.ConfigVersion";
|
||||
private const string PREF_KEY_ENABLE_DATABASE = "UnityEditorToolkit.Database.EnableDatabase";
|
||||
private const string PREF_KEY_FILE_PATH = "UnityEditorToolkit.Database.FilePath";
|
||||
private const string PREF_KEY_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
|
||||
private const string PREF_KEY_ENABLE_ENCRYPTION = "UnityEditorToolkit.Database.EnableEncryption";
|
||||
private const string PREF_KEY_ENCRYPTION_KEY = "UnityEditorToolkit.Database.EncryptionKey";
|
||||
|
||||
/// <summary>
|
||||
/// 현재 설정 버전
|
||||
/// </summary>
|
||||
public const int CURRENT_VERSION = 1;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
/// <summary>
|
||||
/// 설정 버전 (마이그레이션용)
|
||||
/// v1: EnableWAL 기본값 true 변경, EnableDatabase 기본값 true 변경
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private int configVersion = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 기능 활성화 여부
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableDatabase = true;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 파일 경로
|
||||
/// 기본값: Application.persistentDataPath + "/unity_editor_toolkit.db"
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private string databaseFilePath = "";
|
||||
|
||||
/// <summary>
|
||||
/// WAL (Write-Ahead Logging) 모드 사용 여부
|
||||
/// 성능 향상 및 동시성 개선
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableWAL = true;
|
||||
|
||||
/// <summary>
|
||||
/// 암호화 사용 여부 (SQLite Multiple Ciphers)
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableEncryption = false;
|
||||
|
||||
/// <summary>
|
||||
/// 암호화 키 (암호화 사용 시 필요)
|
||||
/// 주의: 평문 저장, 프로덕션에서는 보안 저장소 사용 권장
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private string encryptionKey = "";
|
||||
#endregion
|
||||
|
||||
#region Public Properties
|
||||
public int ConfigVersion
|
||||
{
|
||||
get => configVersion;
|
||||
set => configVersion = value;
|
||||
}
|
||||
|
||||
public bool EnableDatabase
|
||||
{
|
||||
get => enableDatabase;
|
||||
set => enableDatabase = value;
|
||||
}
|
||||
|
||||
public string DatabaseFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
// 빈 문자열이면 기본 경로 반환
|
||||
if (string.IsNullOrEmpty(databaseFilePath))
|
||||
{
|
||||
return GetDefaultDatabasePath();
|
||||
}
|
||||
return databaseFilePath;
|
||||
}
|
||||
set => databaseFilePath = value;
|
||||
}
|
||||
|
||||
public bool EnableWAL
|
||||
{
|
||||
get => enableWAL;
|
||||
set => enableWAL = value;
|
||||
}
|
||||
|
||||
public bool EnableEncryption
|
||||
{
|
||||
get => enableEncryption;
|
||||
set => enableEncryption = value;
|
||||
}
|
||||
|
||||
public string EncryptionKey
|
||||
{
|
||||
get => encryptionKey;
|
||||
set => encryptionKey = value;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection String
|
||||
/// <summary>
|
||||
/// SQLite 연결 문자열 생성
|
||||
/// </summary>
|
||||
/// <returns>SQLite 연결 문자열</returns>
|
||||
public string GetConnectionString()
|
||||
{
|
||||
string dbPath = DatabaseFilePath;
|
||||
|
||||
// 기본 연결 문자열
|
||||
string connectionString = $"Data Source={dbPath}";
|
||||
|
||||
// 암호화 사용 시
|
||||
if (enableEncryption && !string.IsNullOrEmpty(encryptionKey))
|
||||
{
|
||||
connectionString += $";Password={encryptionKey}";
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Static Methods
|
||||
/// <summary>
|
||||
/// 기본 데이터베이스 파일 경로 가져오기
|
||||
/// </summary>
|
||||
/// <returns>기본 경로</returns>
|
||||
public static string GetDefaultDatabasePath()
|
||||
{
|
||||
// Unity persistentDataPath 사용 (플랫폼별 자동 선택)
|
||||
// Windows: %USERPROFILE%\AppData\LocalLow\CompanyName\ProductName
|
||||
// macOS: ~/Library/Application Support/CompanyName/ProductName
|
||||
// Linux: ~/.config/unity3d/CompanyName/ProductName
|
||||
return Path.Combine(Application.persistentDataPath, "unity_editor_toolkit.db");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Validation
|
||||
/// <summary>
|
||||
/// 설정 유효성 검증
|
||||
/// </summary>
|
||||
/// <returns>유효성 검증 결과</returns>
|
||||
public ValidationResult Validate()
|
||||
{
|
||||
// 데이터베이스 비활성화 시 검증 통과
|
||||
if (!enableDatabase)
|
||||
{
|
||||
return new ValidationResult { IsValid = true };
|
||||
}
|
||||
|
||||
// 파일 경로 검증
|
||||
string dbPath = DatabaseFilePath;
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Database file path는 필수 항목입니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 디렉토리 존재 여부 확인 (없으면 생성 가능하도록 경고만)
|
||||
string directory = Path.GetDirectoryName(dbPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"디렉토리 생성 실패: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 암호화 사용 시 키 검증
|
||||
if (enableEncryption && string.IsNullOrWhiteSpace(encryptionKey))
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "암호화 사용 시 Encryption Key는 필수 항목입니다."
|
||||
};
|
||||
}
|
||||
|
||||
return new ValidationResult { IsValid = true };
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
/// <summary>
|
||||
/// 기본값으로 초기화
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
configVersion = CURRENT_VERSION;
|
||||
enableDatabase = true; // SQLite는 설치 불필요, 기본 활성화
|
||||
databaseFilePath = "";
|
||||
enableWAL = true;
|
||||
enableEncryption = false;
|
||||
encryptionKey = "";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region EditorPrefs Save/Load
|
||||
/// <summary>
|
||||
/// EditorPrefs에 개별 키로 저장
|
||||
/// </summary>
|
||||
public void SaveToEditorPrefs()
|
||||
{
|
||||
EditorPrefs.SetInt(PREF_KEY_CONFIG_VERSION, configVersion);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_DATABASE, enableDatabase);
|
||||
EditorPrefs.SetString(PREF_KEY_FILE_PATH, databaseFilePath);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_WAL, enableWAL);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_ENCRYPTION, enableEncryption);
|
||||
EditorPrefs.SetString(PREF_KEY_ENCRYPTION_KEY, encryptionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditorPrefs에서 개별 키로 로드 (마이그레이션 포함)
|
||||
/// </summary>
|
||||
/// <returns>DatabaseConfig 인스턴스</returns>
|
||||
public static DatabaseConfig LoadFromEditorPrefs()
|
||||
{
|
||||
var config = new DatabaseConfig();
|
||||
|
||||
// ConfigVersion 확인 (마이그레이션 판단용)
|
||||
int savedVersion = EditorPrefs.GetInt(PREF_KEY_CONFIG_VERSION, -1);
|
||||
|
||||
if (savedVersion == -1)
|
||||
{
|
||||
// 첫 실행: 기본값 사용 (저장하지 않음)
|
||||
ToolkitLogger.Log("DatabaseConfig", "첫 실행 감지 - 기본값 사용");
|
||||
config.Reset();
|
||||
return config;
|
||||
}
|
||||
|
||||
// 개별 키에서 로드
|
||||
config.configVersion = savedVersion;
|
||||
config.enableDatabase = EditorPrefs.GetBool(PREF_KEY_ENABLE_DATABASE, true);
|
||||
config.databaseFilePath = EditorPrefs.GetString(PREF_KEY_FILE_PATH, "");
|
||||
config.enableWAL = EditorPrefs.GetBool(PREF_KEY_ENABLE_WAL, true);
|
||||
config.enableEncryption = EditorPrefs.GetBool(PREF_KEY_ENABLE_ENCRYPTION, false);
|
||||
config.encryptionKey = EditorPrefs.GetString(PREF_KEY_ENCRYPTION_KEY, "");
|
||||
|
||||
// 마이그레이션 필요 여부 확인
|
||||
if (config.configVersion < CURRENT_VERSION)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", $"마이그레이션: v{config.configVersion} → v{CURRENT_VERSION}");
|
||||
MigrateConfig(config);
|
||||
config.SaveToEditorPrefs(); // 마이그레이션 후 저장
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditorPrefs 키 모두 삭제
|
||||
/// </summary>
|
||||
public static void ClearEditorPrefs()
|
||||
{
|
||||
EditorPrefs.DeleteKey(PREF_KEY_CONFIG_VERSION);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_DATABASE);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_FILE_PATH);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_WAL);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_ENCRYPTION);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENCRYPTION_KEY);
|
||||
ToolkitLogger.Log("DatabaseConfig", "EditorPrefs 초기화 완료");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 설정 마이그레이션 (이전 버전 → 최신 버전)
|
||||
/// </summary>
|
||||
/// <param name="config">마이그레이션할 설정</param>
|
||||
private static void MigrateConfig(DatabaseConfig config)
|
||||
{
|
||||
// v0 → v1: EnableWAL과 EnableDatabase 기본값을 true로 변경
|
||||
if (config.configVersion < 1)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
if (!config.enableWAL)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableWAL을 true로 변경 (v0 → v1)");
|
||||
config.enableWAL = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!config.enableDatabase)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableDatabase를 true로 변경 (v0 → v1)");
|
||||
config.enableDatabase = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "SQLite는 설치가 필요없으므로 Database 기능이 기본 활성화됩니다.");
|
||||
}
|
||||
|
||||
config.configVersion = 1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 설정 유효성 검증 결과
|
||||
/// </summary>
|
||||
public struct ValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 유효성 검증 통과 여부
|
||||
/// </summary>
|
||||
public bool IsValid;
|
||||
|
||||
/// <summary>
|
||||
/// 검증 실패 시 에러 메시지
|
||||
/// </summary>
|
||||
public string ErrorMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f131f462b224eb479a0d27ce5c885b5
|
||||
598
skills/assets/unity-package/Editor/Database/DatabaseManager.cs
Normal file
598
skills/assets/unity-package/Editor/Database/DatabaseManager.cs
Normal file
@@ -0,0 +1,598 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Compilation;
|
||||
using UnityEditorToolkit.Editor.Database.Commands;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 관리 싱글톤
|
||||
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
|
||||
/// Domain Reload 시 자동으로 연결 정리 및 재연결
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public class DatabaseManager
|
||||
{
|
||||
#region Domain Reload Handling
|
||||
private const string PREF_KEY_DB_WAS_CONNECTED = "UnityEditorToolkit.Database.WasConnected";
|
||||
private const string PREF_KEY_DB_PATH = "UnityEditorToolkit.Database.Path";
|
||||
private const string PREF_KEY_DB_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
|
||||
|
||||
static DatabaseManager()
|
||||
{
|
||||
// Domain Reload 전: 연결 정리
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
|
||||
// Domain Reload 후: 자동 재연결
|
||||
EditorApplication.delayCall += OnAfterAssemblyReload;
|
||||
}
|
||||
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
if (instance != null && instance.IsConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "Domain Reload 감지 - 연결 상태 저장 및 정리 중...");
|
||||
|
||||
// 연결 상태 저장
|
||||
EditorPrefs.SetBool(PREF_KEY_DB_WAS_CONNECTED, true);
|
||||
if (instance.config != null)
|
||||
{
|
||||
EditorPrefs.SetString(PREF_KEY_DB_PATH, instance.config.DatabaseFilePath);
|
||||
EditorPrefs.SetBool(PREF_KEY_DB_ENABLE_WAL, instance.config.EnableWAL);
|
||||
}
|
||||
|
||||
// 연결 정리 (동기 방식)
|
||||
try
|
||||
{
|
||||
instance.connector?.DisconnectAsync().Forget();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnAfterAssemblyReload()
|
||||
{
|
||||
// 이전에 연결되어 있었는지 확인
|
||||
bool wasConnected = EditorPrefs.GetBool(PREF_KEY_DB_WAS_CONNECTED, false);
|
||||
|
||||
if (wasConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "Domain Reload 완료 - 자동 재연결 시도...");
|
||||
|
||||
// 연결 상태 플래그 클리어
|
||||
EditorPrefs.DeleteKey(PREF_KEY_DB_WAS_CONNECTED);
|
||||
|
||||
// 설정 복원 및 재연결
|
||||
string dbPath = EditorPrefs.GetString(PREF_KEY_DB_PATH, "");
|
||||
bool enableWAL = EditorPrefs.GetBool(PREF_KEY_DB_ENABLE_WAL, true);
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath))
|
||||
{
|
||||
var config = new DatabaseConfig
|
||||
{
|
||||
DatabaseFilePath = dbPath,
|
||||
EnableWAL = enableWAL
|
||||
};
|
||||
|
||||
// 비동기 재연결
|
||||
Instance.InitializeAsync(config).Forget();
|
||||
ToolkitLogger.Log("DatabaseManager", "자동 재연결 완료.");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Singleton
|
||||
private static DatabaseManager instance;
|
||||
private static readonly object @lock = new object();
|
||||
|
||||
public static DatabaseManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
lock (@lock)
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new DatabaseManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseManager()
|
||||
{
|
||||
// Private constructor for singleton
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
private DatabaseConfig config;
|
||||
private SQLiteConnector connector;
|
||||
private CommandHistory commandHistory;
|
||||
private SyncManager syncManager;
|
||||
private bool isInitialized = false;
|
||||
private bool isConnected = false;
|
||||
private CancellationTokenSource lifecycleCts;
|
||||
private static bool isInitializing = false; // Race condition prevention
|
||||
private static UniTaskCompletionSource<InitializationResult> initializationTcs; // Wait-based: 초기화 결과 공유
|
||||
private bool isMigrationRunning = false; // Migration in progress flag
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 데이터베이스 초기화 완료 여부
|
||||
/// </summary>
|
||||
public bool IsInitialized => isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 상태
|
||||
/// </summary>
|
||||
public bool IsConnected => isConnected && connector != null && connector.IsConnected;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 데이터베이스 설정
|
||||
/// </summary>
|
||||
public DatabaseConfig Config => config;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 커넥터
|
||||
/// </summary>
|
||||
public SQLiteConnector Connector => connector;
|
||||
|
||||
/// <summary>
|
||||
/// Command History (Undo/Redo)
|
||||
/// </summary>
|
||||
public CommandHistory CommandHistory => commandHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Sync Manager (실시간 동기화)
|
||||
/// </summary>
|
||||
public SyncManager SyncManager => syncManager;
|
||||
|
||||
/// <summary>
|
||||
/// 마이그레이션 실행 중 여부
|
||||
/// </summary>
|
||||
public bool IsMigrationRunning => isMigrationRunning;
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
/// <summary>
|
||||
/// 데이터베이스 초기화
|
||||
/// </summary>
|
||||
/// <param name="config">데이터베이스 설정</param>
|
||||
public async UniTask<InitializationResult> InitializeAsync(DatabaseConfig config)
|
||||
{
|
||||
// Wait-based: 이미 진행 중인 초기화가 있으면 완료 대기
|
||||
UniTaskCompletionSource<InitializationResult> existingTcs = null;
|
||||
|
||||
// Race condition prevention: 이미 초기화 진행 중인 경우
|
||||
lock (@lock)
|
||||
{
|
||||
if (isInitializing)
|
||||
{
|
||||
// 진행 중인 초기화가 있으면 TCS 참조 저장 (lock 밖에서 대기)
|
||||
existingTcs = initializationTcs;
|
||||
}
|
||||
else if (isInitialized)
|
||||
{
|
||||
// 이미 초기화된 경우
|
||||
ToolkitLogger.LogWarning("DatabaseManager", "이미 초기화되었습니다. Shutdown 후 재초기화하세요.");
|
||||
return new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Already initialized. Call Shutdown() first."
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// 새로운 초기화 시작
|
||||
isInitializing = true;
|
||||
initializationTcs = new UniTaskCompletionSource<InitializationResult>();
|
||||
}
|
||||
}
|
||||
|
||||
// 진행 중인 초기화가 있으면 완료될 때까지 대기 후 결과 공유
|
||||
if (existingTcs != null)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "다른 초기화가 진행 중입니다. 완료를 대기합니다...");
|
||||
return await existingTcs.Task;
|
||||
}
|
||||
|
||||
// 새로운 초기화 시작
|
||||
InitializationResult result = default;
|
||||
|
||||
try
|
||||
{
|
||||
// 데이터베이스 비활성화 시
|
||||
if (!config.EnableDatabase)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "데이터베이스 기능이 비활성화되어 있습니다.");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Database feature is disabled."
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// 설정 유효성 검증
|
||||
var validation = config.Validate();
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 설정 유효성 검증 실패: {validation.ErrorMessage}");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = validation.ErrorMessage
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// 설정 저장
|
||||
this.config = config;
|
||||
|
||||
// CancellationTokenSource 생성
|
||||
lifecycleCts = new CancellationTokenSource();
|
||||
|
||||
// SQLite 커넥터 생성
|
||||
connector = new SQLiteConnector(this.config);
|
||||
|
||||
// Command History 생성
|
||||
commandHistory = new CommandHistory(this);
|
||||
|
||||
// 연결 테스트
|
||||
var connectResult = await connector.ConnectAsync(lifecycleCts.Token);
|
||||
if (!connectResult.Success)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 연결 실패: {connectResult.ErrorMessage}");
|
||||
await CleanupAsync();
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = connectResult.ErrorMessage
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
isConnected = true;
|
||||
isInitialized = true;
|
||||
|
||||
// 자동 마이그레이션 실행
|
||||
await RunAutoMigrationAsync(lifecycleCts.Token);
|
||||
|
||||
// SyncManager 초기화
|
||||
syncManager = new SyncManager(this);
|
||||
ToolkitLogger.Log("DatabaseManager", "SyncManager initialized.");
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", $" 초기화 완료: {this.config.DatabaseFilePath}");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Initialization successful."
|
||||
};
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 초기화 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
await CleanupAsync();
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// TCS에 결과 설정하여 대기 중인 호출자들에게 결과 전달
|
||||
lock (@lock)
|
||||
{
|
||||
isInitializing = false;
|
||||
var tcs = initializationTcs;
|
||||
initializationTcs = null;
|
||||
tcs?.TrySetResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 종료 및 리소스 정리
|
||||
/// </summary>
|
||||
public async UniTask ShutdownAsync()
|
||||
{
|
||||
if (!isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "Shutting down...");
|
||||
|
||||
try
|
||||
{
|
||||
// CancellationToken 취소
|
||||
lifecycleCts?.Cancel();
|
||||
|
||||
// 리소스 정리
|
||||
await CleanupAsync();
|
||||
|
||||
isInitialized = false;
|
||||
isConnected = false;
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "Shutdown 완료.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외 발생: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내부 리소스 정리
|
||||
/// </summary>
|
||||
private async UniTask CleanupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// SyncManager 정리
|
||||
if (syncManager != null)
|
||||
{
|
||||
syncManager.Dispose();
|
||||
syncManager = null;
|
||||
ToolkitLogger.Log("DatabaseManager", "SyncManager disposed.");
|
||||
}
|
||||
|
||||
// Command History 정리
|
||||
if (commandHistory != null)
|
||||
{
|
||||
commandHistory.Clear();
|
||||
commandHistory = null;
|
||||
}
|
||||
|
||||
// 커넥터 정리
|
||||
if (connector != null)
|
||||
{
|
||||
await connector.DisconnectAsync();
|
||||
connector = null;
|
||||
}
|
||||
|
||||
// CancellationTokenSource 정리
|
||||
lifecycleCts?.Dispose();
|
||||
lifecycleCts = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Cleanup 중 예외 발생: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Management
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (동기 방식 - 서버 종료/Assembly Reload 시 사용)
|
||||
/// </summary>
|
||||
public void Disconnect()
|
||||
{
|
||||
if (!isInitialized || !isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "서버 종료로 인한 연결 해제...");
|
||||
|
||||
try
|
||||
{
|
||||
// CancellationToken 취소
|
||||
lifecycleCts?.Cancel();
|
||||
|
||||
// SyncManager 정리
|
||||
if (syncManager != null)
|
||||
{
|
||||
syncManager.Dispose();
|
||||
syncManager = null;
|
||||
}
|
||||
|
||||
// Command History 정리
|
||||
if (commandHistory != null)
|
||||
{
|
||||
commandHistory.Clear();
|
||||
commandHistory = null;
|
||||
}
|
||||
|
||||
// 커넥터 정리 (동기 방식)
|
||||
connector?.Disconnect();
|
||||
connector = null;
|
||||
|
||||
// CancellationTokenSource 정리
|
||||
lifecycleCts?.Dispose();
|
||||
lifecycleCts = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Disconnect 중 예외: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 상태 업데이트
|
||||
isConnected = false;
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "연결 해제 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 상태 확인
|
||||
/// </summary>
|
||||
public async UniTask<bool> TestConnectionAsync()
|
||||
{
|
||||
if (!isInitialized || connector == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await connector.TestConnectionAsync(lifecycleCts?.Token ?? default);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 연결 테스트 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 재시도
|
||||
/// </summary>
|
||||
public async UniTask<bool> ReconnectAsync()
|
||||
{
|
||||
if (!isInitialized || config == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", "초기화되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "재연결 시도 중...");
|
||||
|
||||
// 기존 연결 종료
|
||||
if (connector != null)
|
||||
{
|
||||
await connector.DisconnectAsync();
|
||||
}
|
||||
|
||||
// 새 커넥터 생성
|
||||
connector = new SQLiteConnector(config);
|
||||
|
||||
// 연결
|
||||
var result = await connector.ConnectAsync(lifecycleCts?.Token ?? default);
|
||||
isConnected = result.Success;
|
||||
|
||||
if (isConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "재연결 성공.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 재연결 실패: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return isConnected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 재연결 중 예외 발생: {ex.Message}");
|
||||
isConnected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Auto Migration
|
||||
/// <summary>
|
||||
/// 자동 마이그레이션 실행 (연결 시 자동 호출)
|
||||
/// </summary>
|
||||
private async UniTask RunAutoMigrationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
isMigrationRunning = true;
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "자동 마이그레이션 확인 중...");
|
||||
|
||||
var migrationRunner = new MigrationRunner(this);
|
||||
var result = await migrationRunner.RunMigrationsAsync(cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.MigrationsApplied > 0)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", $" 자동 마이그레이션 완료: {result.MigrationsApplied}개 적용됨");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "마이그레이션이 최신 상태입니다.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 실패: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 중 예외 발생: {ex.Message}");
|
||||
// 마이그레이션 실패해도 연결은 유지
|
||||
}
|
||||
finally
|
||||
{
|
||||
isMigrationRunning = false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Health Check
|
||||
/// <summary>
|
||||
/// 데이터베이스 상태 정보 조회
|
||||
/// </summary>
|
||||
public DatabaseHealthStatus GetHealthStatus()
|
||||
{
|
||||
return new DatabaseHealthStatus
|
||||
{
|
||||
IsInitialized = isInitialized,
|
||||
IsConnected = IsConnected,
|
||||
IsEnabled = config?.EnableDatabase ?? false,
|
||||
DatabaseFilePath = config?.DatabaseFilePath ?? "N/A",
|
||||
DatabaseFileExists = connector?.DatabaseFileExists() ?? false
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 초기화 결과
|
||||
/// </summary>
|
||||
public struct InitializationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 상태
|
||||
/// </summary>
|
||||
public struct DatabaseHealthStatus
|
||||
{
|
||||
public bool IsInitialized;
|
||||
public bool IsConnected;
|
||||
public bool IsEnabled;
|
||||
public string DatabaseFilePath;
|
||||
public bool DatabaseFileExists;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[DatabaseHealthStatus]\n" +
|
||||
$" Initialized: {IsInitialized}\n" +
|
||||
$" Connected: {IsConnected}\n" +
|
||||
$" Enabled: {IsEnabled}\n" +
|
||||
$" Database File: {DatabaseFilePath}\n" +
|
||||
$" File Exists: {DatabaseFileExists}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c63a562fc80ebc42a10afeb117fa4a2
|
||||
596
skills/assets/unity-package/Editor/Database/MigrationRunner.cs
Normal file
596
skills/assets/unity-package/Editor/Database/MigrationRunner.cs
Normal file
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// 데이터베이스 마이그레이션 자동 실행
|
||||
/// SQL 파일을 순서대로 실행하여 스키마 버전 관리
|
||||
/// SQLite 버전 - 트랜잭션 지원
|
||||
/// </summary>
|
||||
public class MigrationRunner
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseManager databaseManager;
|
||||
private readonly string migrationsPath;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public MigrationRunner(DatabaseManager databaseManager, string migrationsPath = null)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
|
||||
// 마이그레이션 폴더 경로 (기본값: Editor/Database/Migrations)
|
||||
if (string.IsNullOrEmpty(migrationsPath))
|
||||
{
|
||||
// Unity 패키지 내 Migrations 폴더 경로
|
||||
this.migrationsPath = Path.Combine(Application.dataPath, "..", "Packages",
|
||||
"com.devgom.unity-editor-toolkit", "Editor", "Database", "Migrations");
|
||||
}
|
||||
else
|
||||
{
|
||||
this.migrationsPath = migrationsPath;
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 생성 완료. Migrations Path: {this.migrationsPath}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Migration Execution
|
||||
/// <summary>
|
||||
/// 모든 마이그레이션 실행 (순서대로)
|
||||
/// </summary>
|
||||
public async UniTask<MigrationResult> RunMigrationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "DatabaseManager not initialized or not connected."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "마이그레이션 시작...");
|
||||
|
||||
// 1. migrations 테이블 생성 (존재하지 않으면)
|
||||
await EnsureMigrationTableExistsAsync(cancellationToken);
|
||||
|
||||
// 2. 실행된 마이그레이션 목록 조회
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 이미 실행된 마이그레이션: {appliedMigrations.Count}개");
|
||||
|
||||
// 3. 마이그레이션 파일 목록 조회
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
if (migrationFiles.Count == 0)
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" 마이그레이션 파일이 없습니다: {migrationsPath}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "No migration files found.",
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 발견된 마이그레이션 파일: {migrationFiles.Count}개");
|
||||
|
||||
// 4. 미실행 마이그레이션 필터링
|
||||
var pendingMigrations = migrationFiles
|
||||
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
|
||||
.OrderBy(file => file)
|
||||
.ToList();
|
||||
|
||||
if (pendingMigrations.Count == 0)
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "실행할 마이그레이션이 없습니다.");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "All migrations already applied.",
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 실행할 마이그레이션: {pendingMigrations.Count}개");
|
||||
|
||||
// 5. 마이그레이션 실행
|
||||
int appliedCount = 0;
|
||||
foreach (var migrationFile in pendingMigrations)
|
||||
{
|
||||
string migrationName = Path.GetFileNameWithoutExtension(migrationFile);
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 실행 중: {migrationName}");
|
||||
|
||||
var result = await ApplyMigrationAsync(migrationFile, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패: {migrationName}\n{result.ErrorMessage}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = $"Failed to apply migration: {migrationName}\n{result.ErrorMessage}",
|
||||
MigrationsApplied = appliedCount
|
||||
};
|
||||
}
|
||||
|
||||
appliedCount++;
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 완료: {migrationName}");
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 완료: {appliedCount}개 적용됨");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Successfully applied {appliedCount} migration(s).",
|
||||
MigrationsApplied = appliedCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 마이그레이션 실행
|
||||
/// </summary>
|
||||
private async UniTask<MigrationResult> ApplyMigrationAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// SQL 파일 읽기
|
||||
string sql = File.ReadAllText(filePath);
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Migration file is empty."
|
||||
};
|
||||
}
|
||||
|
||||
string migrationName = Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
|
||||
// 트랜잭션으로 마이그레이션 원자성 보장
|
||||
// 마이그레이션 중 오류 발생 시 롤백으로 데이터베이스 일관성 유지
|
||||
connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
// SQL을 완전한 문장 단위로 분리 (BEGIN...END 블록 고려)
|
||||
var sqlStatements = SplitSqlStatements(sql);
|
||||
int executedCount = 0;
|
||||
|
||||
foreach (var statement in sqlStatements)
|
||||
{
|
||||
var trimmedStatement = statement.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedStatement))
|
||||
continue;
|
||||
|
||||
// 주석만 있는 문장 스킵 (Execute()에 전달하면 "not an error" 발생)
|
||||
string withoutComments = RemoveSqlComments(trimmedStatement);
|
||||
if (string.IsNullOrWhiteSpace(withoutComments))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 주석만 있는 문장 스킵 (RemoveSqlComments v2 적용됨)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석이 제거된 SQL 사용 (Execute()가 주석을 처리하지 못할 수 있음)
|
||||
string cleanedSql = withoutComments.Trim();
|
||||
|
||||
// SELECT 문 (결과 메시지용)은 스킵 - Execute()는 결과를 반환하지 않음
|
||||
if (cleanedSql.StartsWith("SELECT ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" SELECT 문 스킵");
|
||||
continue;
|
||||
}
|
||||
|
||||
// PRAGMA 문은 스킵 (SQLiteConnector에서 이미 설정됨)
|
||||
if (cleanedSql.StartsWith("PRAGMA ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" PRAGMA 문 스킵: {cleanedSql}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL 실행 (오류 발생 시 어떤 문에서 발생했는지 확인용)
|
||||
try
|
||||
{
|
||||
connection.Execute(cleanedSql);
|
||||
executedCount++;
|
||||
}
|
||||
catch (Exception sqlEx)
|
||||
{
|
||||
// SQL 문의 첫 100자만 출력 (너무 길면 로그가 지저분해짐)
|
||||
string sqlPreview = trimmedStatement.Length > 100
|
||||
? trimmedStatement.Substring(0, 100) + "..."
|
||||
: trimmedStatement;
|
||||
ToolkitLogger.LogError("MigrationRunner", $" SQL 실행 실패 ({executedCount + 1}번째): {sqlEx.Message}\nSQL: {sqlPreview}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" SQL 문장 실행 완료: {executedCount}개");
|
||||
|
||||
// migrations 테이블에 기록
|
||||
string insertSql = @"
|
||||
INSERT INTO migrations (migration_name, applied_at)
|
||||
VALUES (?, datetime('now'));";
|
||||
|
||||
connection.Execute(insertSql, migrationName);
|
||||
|
||||
// 트랜잭션 커밋
|
||||
connection.Commit();
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 트랜잭션 커밋 완료: {migrationName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 트랜잭션 롤백
|
||||
connection.Rollback();
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패 - 롤백됨: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return new MigrationResult { Success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Migration Table Management
|
||||
/// <summary>
|
||||
/// migrations 테이블 생성 (존재하지 않으면)
|
||||
/// </summary>
|
||||
private async UniTask EnsureMigrationTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string createTableSql = @"
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
migration_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
migration_name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_migrations_name ON migrations(migration_name);
|
||||
";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
connection.Execute(createTableSql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "migrations 테이블 확인 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행된 마이그레이션 목록 조회
|
||||
/// </summary>
|
||||
private async UniTask<List<string>> GetAppliedMigrationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var appliedMigrations = new List<string>();
|
||||
|
||||
string selectSql = "SELECT migration_name FROM migrations ORDER BY migration_id ASC;";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
var results = connection.Query<MigrationRecord>(selectSql);
|
||||
|
||||
foreach (var record in results)
|
||||
{
|
||||
appliedMigrations.Add(record.migration_name);
|
||||
}
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return appliedMigrations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migration 레코드 (SQLite 쿼리 결과용)
|
||||
/// </summary>
|
||||
private class MigrationRecord
|
||||
{
|
||||
public string migration_name { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Pending Migration Check
|
||||
/// <summary>
|
||||
/// 대기 중인 마이그레이션 수 조회 (UI 표시용)
|
||||
/// </summary>
|
||||
public async UniTask<int> GetPendingMigrationCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return -1; // 연결 안됨
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// migrations 테이블 확인
|
||||
await EnsureMigrationTableExistsAsync(cancellationToken);
|
||||
|
||||
// 실행된 마이그레이션 목록 조회
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
|
||||
|
||||
// 마이그레이션 파일 목록 조회
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
|
||||
// 미실행 마이그레이션 필터링
|
||||
var pendingMigrations = migrationFiles
|
||||
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
|
||||
.ToList();
|
||||
|
||||
return pendingMigrations.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" 펜딩 마이그레이션 확인 실패: {ex.Message}");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region File Discovery
|
||||
/// <summary>
|
||||
/// 마이그레이션 파일 목록 조회 (.sql 파일)
|
||||
/// </summary>
|
||||
private List<string> GetMigrationFiles()
|
||||
{
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" Migrations 폴더가 존재하지 않습니다: {migrationsPath}");
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(migrationsPath, "*.sql", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(file => file)
|
||||
.ToList();
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL 문에서 주석을 제거 (Execute() 호출 전 유효성 검증용)
|
||||
/// </summary>
|
||||
private string RemoveSqlComments(string sql)
|
||||
{
|
||||
var result = new System.Text.StringBuilder();
|
||||
bool inSingleLineComment = false;
|
||||
bool inMultiLineComment = false;
|
||||
bool inString = false;
|
||||
|
||||
for (int i = 0; i < sql.Length; i++)
|
||||
{
|
||||
char c = sql[i];
|
||||
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||
|
||||
// 줄바꿈 처리 (단일 줄 주석 종료)
|
||||
if (c == '\n' || c == '\r')
|
||||
{
|
||||
inSingleLineComment = false;
|
||||
if (!inMultiLineComment)
|
||||
{
|
||||
result.Append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단일 줄 주석 시작
|
||||
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
|
||||
{
|
||||
inSingleLineComment = true;
|
||||
i++; // 두 번째 '-' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 시작
|
||||
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
|
||||
{
|
||||
inMultiLineComment = true;
|
||||
i++; // '*' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 종료
|
||||
if (inMultiLineComment && c == '*' && nextChar == '/')
|
||||
{
|
||||
inMultiLineComment = false;
|
||||
i++; // '/' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석 내부면 스킵
|
||||
if (inSingleLineComment || inMultiLineComment)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 시작/종료 (작은따옴표)
|
||||
if (c == '\'')
|
||||
{
|
||||
// 이스케이프된 따옴표 확인 ('')
|
||||
if (inString && nextChar == '\'')
|
||||
{
|
||||
result.Append(c);
|
||||
i++; // 다음 따옴표도 추가
|
||||
result.Append('\'');
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
result.Append(c);
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL 문장을 BEGIN...END 블록을 고려하여 분리
|
||||
/// </summary>
|
||||
private List<string> SplitSqlStatements(string sql)
|
||||
{
|
||||
var statements = new List<string>();
|
||||
var currentStatement = new System.Text.StringBuilder();
|
||||
int beginEndDepth = 0;
|
||||
bool inSingleLineComment = false;
|
||||
bool inMultiLineComment = false;
|
||||
bool inString = false;
|
||||
|
||||
for (int i = 0; i < sql.Length; i++)
|
||||
{
|
||||
char c = sql[i];
|
||||
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||
|
||||
// 줄바꿈 처리 (단일 줄 주석 종료)
|
||||
if (c == '\n')
|
||||
{
|
||||
inSingleLineComment = false;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단일 줄 주석 시작 (문자열 내부가 아닐 때)
|
||||
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
|
||||
{
|
||||
inSingleLineComment = true;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 시작
|
||||
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
|
||||
{
|
||||
inMultiLineComment = true;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 종료
|
||||
if (inMultiLineComment && c == '*' && nextChar == '/')
|
||||
{
|
||||
inMultiLineComment = false;
|
||||
currentStatement.Append(c);
|
||||
i++; // '/' 스킵
|
||||
currentStatement.Append('/');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석 내부면 그대로 추가
|
||||
if (inSingleLineComment || inMultiLineComment)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 시작/종료 (작은따옴표)
|
||||
if (c == '\'')
|
||||
{
|
||||
// 이스케이프된 따옴표 확인 ('')
|
||||
if (inString && nextChar == '\'')
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
i++; // 다음 따옴표도 추가
|
||||
currentStatement.Append('\'');
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 내부면 그대로 추가
|
||||
if (inString)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// BEGIN 키워드 감지 (대소문자 무시)
|
||||
if (i + 5 <= sql.Length)
|
||||
{
|
||||
string word = sql.Substring(i, 5).ToUpper();
|
||||
if (word == "BEGIN" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
|
||||
(i + 5 >= sql.Length || !char.IsLetterOrDigit(sql[i + 5])))
|
||||
{
|
||||
beginEndDepth++;
|
||||
}
|
||||
}
|
||||
|
||||
// END 키워드 감지
|
||||
if (i + 3 <= sql.Length)
|
||||
{
|
||||
string word = sql.Substring(i, 3).ToUpper();
|
||||
if (word == "END" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
|
||||
(i + 3 >= sql.Length || !char.IsLetterOrDigit(sql[i + 3])))
|
||||
{
|
||||
beginEndDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
// 세미콜론으로 문장 분리 (BEGIN...END 블록 외부에서만)
|
||||
if (c == ';' && beginEndDepth == 0)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
string statement = currentStatement.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(statement))
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
currentStatement.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
currentStatement.Append(c);
|
||||
}
|
||||
|
||||
// 마지막 문장 추가 (세미콜론 없이 끝나는 경우)
|
||||
string lastStatement = currentStatement.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(lastStatement))
|
||||
{
|
||||
statements.Add(lastStatement);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 마이그레이션 결과
|
||||
/// </summary>
|
||||
public struct MigrationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
public int MigrationsApplied;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7efe4a498e5f5254ba0be804329e8fd1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1056308bc40a585459dd7361bc65fd71
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,358 @@
|
||||
-- Migration 001: Initial Schema (SQLite)
|
||||
-- Unity Editor Toolkit - SQLite Database Schema
|
||||
-- Embedded SQLite (설치 불필요)
|
||||
-- Created: 2025-11-14
|
||||
-- Updated: 2025-11-14 (PostgreSQL → SQLite)
|
||||
|
||||
-- ============================================================
|
||||
-- PRAGMA 설정
|
||||
-- ============================================================
|
||||
|
||||
-- 참고: PRAGMA 설정은 SQLiteConnector.ConnectAsync()에서 자동으로 적용됩니다.
|
||||
-- - foreign_keys = ON (Foreign Key 제약 활성화)
|
||||
-- - journal_mode = WAL (Write-Ahead Logging, 성능 향상)
|
||||
-- - synchronous = NORMAL (fsync 최적화)
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 1: scenes
|
||||
-- 씬 정보 (프로젝트의 모든 씬)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scenes (
|
||||
scene_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_name TEXT NOT NULL,
|
||||
scene_path TEXT NOT NULL UNIQUE,
|
||||
build_index INTEGER,
|
||||
is_loaded INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (0/1)
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scenes_name ON scenes(scene_name);
|
||||
CREATE INDEX idx_scenes_is_loaded ON scenes(is_loaded);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 2: gameobjects
|
||||
-- GameObject 정보 (Closure Table로 계층 구조 관리)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gameobjects (
|
||||
object_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL UNIQUE,
|
||||
scene_id INTEGER REFERENCES scenes(scene_id) ON DELETE CASCADE,
|
||||
object_name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES gameobjects(object_id) ON DELETE SET NULL,
|
||||
tag TEXT DEFAULT 'Untagged',
|
||||
layer INTEGER DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
|
||||
is_static INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (Soft delete)
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_gameobjects_instance_id ON gameobjects(instance_id);
|
||||
CREATE INDEX idx_gameobjects_scene_id ON gameobjects(scene_id);
|
||||
CREATE INDEX idx_gameobjects_parent_id ON gameobjects(parent_id);
|
||||
CREATE INDEX idx_gameobjects_tag ON gameobjects(tag);
|
||||
CREATE INDEX idx_gameobjects_is_active ON gameobjects(is_active);
|
||||
CREATE INDEX idx_gameobjects_is_deleted ON gameobjects(is_deleted);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 2-1: gameobject_closure
|
||||
-- GameObject 계층 구조 (Closure Table)
|
||||
-- PostgreSQL ltree 대체 - 성능이 더 우수함
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gameobject_closure (
|
||||
ancestor_id INTEGER NOT NULL,
|
||||
descendant_id INTEGER NOT NULL,
|
||||
depth INTEGER NOT NULL,
|
||||
PRIMARY KEY (ancestor_id, descendant_id),
|
||||
FOREIGN KEY (ancestor_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (descendant_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_closure_ancestor ON gameobject_closure(ancestor_id);
|
||||
CREATE INDEX idx_closure_descendant ON gameobject_closure(descendant_id);
|
||||
CREATE INDEX idx_closure_depth ON gameobject_closure(depth);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 3: components
|
||||
-- Component 정보
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS components (
|
||||
component_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
component_type TEXT NOT NULL,
|
||||
component_data TEXT CHECK(json_valid(component_data)), -- JSON validation
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_components_object_id ON components(object_id);
|
||||
CREATE INDEX idx_components_type ON components(component_type);
|
||||
-- SQLite JSON index는 json_each/json_tree로 쿼리 시 자동 활용
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 4: transforms
|
||||
-- Transform 히스토리 (위치, 회전, 스케일 변경 추적)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transforms (
|
||||
transform_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
position_x REAL NOT NULL,
|
||||
position_y REAL NOT NULL,
|
||||
position_z REAL NOT NULL,
|
||||
rotation_x REAL NOT NULL, -- Quaternion X
|
||||
rotation_y REAL NOT NULL, -- Quaternion Y
|
||||
rotation_z REAL NOT NULL, -- Quaternion Z
|
||||
rotation_w REAL NOT NULL, -- Quaternion W
|
||||
scale_x REAL NOT NULL,
|
||||
scale_y REAL NOT NULL,
|
||||
scale_z REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transforms_object_id ON transforms(object_id);
|
||||
CREATE INDEX idx_transforms_recorded_at ON transforms(recorded_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 5: command_history
|
||||
-- 명령 히스토리 (Command Pattern, Undo/Redo 지원)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
-- Primary Key: GUID 문자열 (C# Guid.NewGuid().ToString())
|
||||
command_id TEXT PRIMARY KEY,
|
||||
|
||||
-- Command 정보
|
||||
command_name TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
command_data TEXT NOT NULL CHECK(json_valid(command_data)), -- JSON validation
|
||||
|
||||
-- 실행 정보
|
||||
executed_at TEXT NOT NULL,
|
||||
executed_by TEXT DEFAULT 'System',
|
||||
|
||||
-- 메타데이터
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_command_history_executed_at ON command_history(executed_at DESC);
|
||||
CREATE INDEX idx_command_history_type ON command_history(command_type);
|
||||
CREATE INDEX idx_command_history_name ON command_history(command_name);
|
||||
CREATE INDEX idx_command_history_executed_by ON command_history(executed_by);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 6: snapshots
|
||||
-- 씬 스냅샷 (시점 복원용)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_id INTEGER NOT NULL REFERENCES scenes(scene_id) ON DELETE CASCADE,
|
||||
snapshot_name TEXT NOT NULL,
|
||||
snapshot_data TEXT NOT NULL CHECK(json_valid(snapshot_data)), -- JSON validation
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_scene_id ON snapshots(scene_id);
|
||||
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 7: metadata
|
||||
-- 프로젝트 메타데이터
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
metadata_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_metadata_key ON metadata(key);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 8: analytics_cache
|
||||
-- 분석 결과 캐시 (성능 최적화)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_cache (
|
||||
cache_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cache_key TEXT NOT NULL UNIQUE,
|
||||
cache_data TEXT NOT NULL CHECK(json_valid(cache_data)), -- JSON validation
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_analytics_cache_key ON analytics_cache(cache_key);
|
||||
CREATE INDEX idx_analytics_cache_expires_at ON analytics_cache(expires_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================
|
||||
|
||||
-- gameobjects.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_gameobjects_updated_at
|
||||
AFTER UPDATE ON gameobjects
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE gameobjects SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE object_id = NEW.object_id;
|
||||
END;
|
||||
|
||||
-- components.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_components_updated_at
|
||||
AFTER UPDATE ON components
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE components SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE component_id = NEW.component_id;
|
||||
END;
|
||||
|
||||
-- scenes.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_scenes_updated_at
|
||||
AFTER UPDATE ON scenes
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE scenes SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE scene_id = NEW.scene_id;
|
||||
END;
|
||||
|
||||
-- metadata.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_metadata_updated_at
|
||||
AFTER UPDATE ON metadata
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE metadata SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE metadata_id = NEW.metadata_id;
|
||||
END;
|
||||
|
||||
-- ============================================================
|
||||
-- CLOSURE TABLE TRIGGERS (자동 유지 관리)
|
||||
-- ============================================================
|
||||
|
||||
-- GameObject 생성 시: 자기 자신 + 부모 조상들 추가
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_gameobject_insert_closure
|
||||
AFTER INSERT ON gameobjects
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- 1. 자기 자신 (depth = 0)
|
||||
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
|
||||
VALUES (NEW.object_id, NEW.object_id, 0);
|
||||
|
||||
-- 2. 부모가 있으면 부모의 모든 조상 복사 (depth + 1)
|
||||
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
|
||||
SELECT ancestor_id, NEW.object_id, depth + 1
|
||||
FROM gameobject_closure
|
||||
WHERE descendant_id = NEW.parent_id AND NEW.parent_id IS NOT NULL;
|
||||
END;
|
||||
|
||||
-- GameObject 삭제 시: 관련 Closure 레코드 자동 삭제 (CASCADE)
|
||||
-- (FOREIGN KEY ON DELETE CASCADE가 처리)
|
||||
|
||||
-- ============================================================
|
||||
-- VIEWS
|
||||
-- ============================================================
|
||||
|
||||
-- Active GameObjects View (is_deleted = 0, is_active = 1)
|
||||
CREATE VIEW IF NOT EXISTS active_gameobjects AS
|
||||
SELECT
|
||||
object_id,
|
||||
instance_id,
|
||||
scene_id,
|
||||
object_name,
|
||||
parent_id,
|
||||
tag,
|
||||
layer,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM gameobjects
|
||||
WHERE is_deleted = 0 AND is_active = 1;
|
||||
|
||||
-- GameObject Component Count View
|
||||
CREATE VIEW IF NOT EXISTS gameobject_component_count AS
|
||||
SELECT
|
||||
g.object_id,
|
||||
g.object_name,
|
||||
g.parent_id,
|
||||
COUNT(c.component_id) AS component_count
|
||||
FROM gameobjects g
|
||||
LEFT JOIN components c ON g.object_id = c.object_id
|
||||
WHERE g.is_deleted = 0
|
||||
GROUP BY g.object_id, g.object_name, g.parent_id;
|
||||
|
||||
-- Recent Commands View
|
||||
CREATE VIEW IF NOT EXISTS recent_commands AS
|
||||
SELECT
|
||||
command_id,
|
||||
command_name,
|
||||
command_type,
|
||||
executed_at,
|
||||
executed_by
|
||||
FROM command_history
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
-- Command Statistics View
|
||||
CREATE VIEW IF NOT EXISTS command_statistics AS
|
||||
SELECT
|
||||
command_type,
|
||||
COUNT(*) AS command_count,
|
||||
MIN(executed_at) AS first_executed,
|
||||
MAX(executed_at) AS last_executed
|
||||
FROM command_history
|
||||
GROUP BY command_type
|
||||
ORDER BY command_count DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- INITIAL DATA
|
||||
-- ============================================================
|
||||
|
||||
-- 기본 메타데이터 삽입
|
||||
INSERT OR IGNORE INTO metadata (key, value) VALUES
|
||||
('schema_version', '1'),
|
||||
('database_type', 'SQLite'),
|
||||
('database_created_at', datetime('now')),
|
||||
('unity_editor_toolkit_version', '0.5.0');
|
||||
|
||||
-- ============================================================
|
||||
-- COMPLETION
|
||||
-- ============================================================
|
||||
|
||||
-- SQLite는 RAISE NOTICE가 없으므로 SELECT로 완료 메시지 출력
|
||||
SELECT '==========================================================' AS message;
|
||||
SELECT 'Migration 001: Initial Schema (SQLite) - 완료' AS message;
|
||||
SELECT '==========================================================' AS message;
|
||||
SELECT '생성된 테이블: 9개' AS message;
|
||||
SELECT ' - scenes, gameobjects, gameobject_closure, components' AS message;
|
||||
SELECT ' - transforms, command_history, snapshots, metadata, analytics_cache' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 인덱스: 23개' AS message;
|
||||
SELECT ' - gameobject_closure: 3개 (ancestor, descendant, depth)' AS message;
|
||||
SELECT ' - command_history: 4개 (GUID, executed_at, type, name, executed_by)' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 트리거: 5개' AS message;
|
||||
SELECT ' - updated_at 자동 업데이트 (gameobjects, components, scenes, metadata)' AS message;
|
||||
SELECT ' - Closure Table 자동 유지 (gameobject_insert_closure)' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 뷰: 4개' AS message;
|
||||
SELECT ' - active_gameobjects, gameobject_component_count' AS message;
|
||||
SELECT ' - recent_commands, command_statistics' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT 'Closure Table 계층 구조 쿼리 예시:' AS message;
|
||||
SELECT ' -- 특정 GameObject의 모든 자식 (ltree 대체):' AS message;
|
||||
SELECT ' SELECT g.* FROM gameobjects g' AS message;
|
||||
SELECT ' INNER JOIN gameobject_closure c ON g.object_id = c.descendant_id' AS message;
|
||||
SELECT ' WHERE c.ancestor_id = ? AND c.depth > 0;' AS message;
|
||||
SELECT '==========================================================' AS message;
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5345bdb2f717d2e409cdd34c2a7262a3
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration 002: Add GUID to GameObject
|
||||
-- Unity Editor Toolkit - Add persistent GUID column to gameobjects table
|
||||
-- Created: 2025-11-18
|
||||
-- Purpose: Replace instance_id with GUID for persistent GameObject identification
|
||||
|
||||
-- ============================================================
|
||||
-- ALTER TABLE: gameobjects
|
||||
-- Add guid column for persistent identification
|
||||
-- ============================================================
|
||||
|
||||
-- Add guid column (nullable initially for migration)
|
||||
ALTER TABLE gameobjects ADD COLUMN guid TEXT;
|
||||
|
||||
-- Create unique index on guid (after data migration)
|
||||
-- Note: Index will be created after populating existing rows with GUIDs
|
||||
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);
|
||||
|
||||
-- ============================================================
|
||||
-- Migration Notes
|
||||
-- ============================================================
|
||||
--
|
||||
-- GUID migration strategy:
|
||||
-- 1. Add guid column (nullable)
|
||||
-- 2. Existing rows will have NULL guid
|
||||
-- 3. SyncManager will generate GUIDs when syncing GameObjects
|
||||
-- 4. GameObjectGuid component ensures all GameObjects have GUIDs
|
||||
-- 5. After full scene sync, make guid NOT NULL and UNIQUE
|
||||
--
|
||||
-- Future migration (Migration_003):
|
||||
-- ALTER TABLE gameobjects ALTER COLUMN guid SET NOT NULL;
|
||||
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d06228fa3660f4f43a33b92f2cc4e994
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 11500000, guid: ba03993677dd740da91b9a13653538e9, type: 3}
|
||||
8
skills/assets/unity-package/Editor/Database/Models.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Models.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6b69cd3d00bc704c863538202e5efb3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
skills/assets/unity-package/Editor/Database/Queries.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Queries.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8952d02cfc5f21e4b82dc98c5e3e0d21
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
344
skills/assets/unity-package/Editor/Database/SQLiteConnector.cs
Normal file
344
skills/assets/unity-package/Editor/Database/SQLiteConnector.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using SQLite;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 커넥터
|
||||
/// 임베디드 SQLite - 설치 불필요
|
||||
/// </summary>
|
||||
public class SQLiteConnector
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseConfig config;
|
||||
private SQLiteConnection connection;
|
||||
private bool isConnected;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 연결 상태
|
||||
/// </summary>
|
||||
public bool IsConnected => isConnected && connection != null;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 연결 객체
|
||||
/// </summary>
|
||||
public SQLiteConnection Connection => connection;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public SQLiteConnector(DatabaseConfig config)
|
||||
{
|
||||
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Management
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결
|
||||
/// </summary>
|
||||
public async UniTask<ConnectionResult> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isConnected)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", "Already connected.");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Already connected."
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", $" Connecting to: {config.DatabaseFilePath}");
|
||||
|
||||
// SQLite 연결 생성 (동기 작업이지만 UniTask로 래핑)
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
// SQLite 연결 옵션 설정
|
||||
var options = new SQLiteConnectionString(
|
||||
config.DatabaseFilePath,
|
||||
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex,
|
||||
storeDateTimeAsTicks: true
|
||||
);
|
||||
|
||||
connection = new SQLiteConnection(options);
|
||||
|
||||
// WAL 모드 설정 (성능 향상)
|
||||
if (config.EnableWAL)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA journal_mode=WAL;");
|
||||
ToolkitLogger.Log("SQLiteConnector", "WAL mode enabled.");
|
||||
}
|
||||
catch (Exception walEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL mode failed (continuing without WAL): {walEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign Keys 활성화
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA foreign_keys=ON;");
|
||||
}
|
||||
catch (Exception fkEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" Foreign keys activation failed: {fkEx.Message}");
|
||||
}
|
||||
|
||||
// Synchronous 설정 (NORMAL = 안전하면서도 빠름)
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA synchronous=NORMAL;");
|
||||
}
|
||||
catch (Exception syncEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" Synchronous setting failed: {syncEx.Message}");
|
||||
}
|
||||
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
isConnected = true;
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", $" Connected successfully: {config.DatabaseFilePath}");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Connection successful."
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", "Connection cancelled.");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Connection cancelled."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Connection failed: {ex.Message}\n{ex.StackTrace}");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (비동기)
|
||||
/// </summary>
|
||||
public async UniTask DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnecting...");
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
DisconnectInternal();
|
||||
});
|
||||
|
||||
isConnected = false;
|
||||
|
||||
// 강제 GC (파일 핸들 해제를 위해)
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnected.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (동기 - Assembly Reload/서버 종료 시 사용)
|
||||
/// </summary>
|
||||
public void Disconnect()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnecting (sync)...");
|
||||
|
||||
DisconnectInternal();
|
||||
|
||||
isConnected = false;
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnected (sync).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내부 연결 해제 로직
|
||||
/// </summary>
|
||||
private void DisconnectInternal()
|
||||
{
|
||||
// WAL 체크포인트 수행 (파일 잠금 해제를 위해)
|
||||
if (config.EnableWAL && connection != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
ToolkitLogger.Log("SQLiteConnector", "WAL checkpoint completed.");
|
||||
}
|
||||
catch (Exception walEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL checkpoint failed: {walEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
connection?.Close();
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 테스트
|
||||
/// </summary>
|
||||
public async UniTask<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 간단한 쿼리로 연결 테스트
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
connection.ExecuteScalar<int>("SELECT 1;");
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Connection test failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Database Operations
|
||||
/// <summary>
|
||||
/// SQL 스크립트 실행 (마이그레이션용)
|
||||
/// </summary>
|
||||
public async UniTask<int> ExecuteScriptAsync(string sql, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database is not connected.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
return connection.Execute(sql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Execute script failed: {ex.Message}\n{sql}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 값 조회
|
||||
/// </summary>
|
||||
public async UniTask<T> ExecuteScalarAsync<T>(string sql, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database is not connected.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
return connection.ExecuteScalar<T>(sql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Execute scalar failed: {ex.Message}\n{sql}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 존재 여부 확인
|
||||
/// </summary>
|
||||
public bool DatabaseFileExists()
|
||||
{
|
||||
return System.IO.File.Exists(config.DatabaseFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 버전 조회
|
||||
/// </summary>
|
||||
public async UniTask<string> GetDatabaseVersionAsync()
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
return "Not Connected";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var result = connection.ExecuteScalar<string>("SELECT sqlite_version();");
|
||||
return $"SQLite {result}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Failed to get version: {ex.Message}");
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Struct
|
||||
/// <summary>
|
||||
/// 연결 결과
|
||||
/// </summary>
|
||||
public struct ConnectionResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad42d3641d3371b4d890f2ebed149635
|
||||
8
skills/assets/unity-package/Editor/Database/Setup.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Setup.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8dc1710d9b974e47a258b98c60eb5a6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 자동 생성
|
||||
/// 파일 기반 DB이므로 설치 불필요
|
||||
/// </summary>
|
||||
public class DatabaseCreator
|
||||
{
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 생성 및 확인
|
||||
/// </summary>
|
||||
public async UniTask<DatabaseCreationResult> CreateDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 확인 시작: {config.DatabaseFilePath}");
|
||||
|
||||
// 1. 데이터베이스 파일 존재 여부 확인
|
||||
if (File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
long fileSize = new FileInfo(config.DatabaseFilePath).Length;
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath} ({fileSize} bytes)");
|
||||
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Database file already exists: {config.DatabaseFilePath}",
|
||||
AlreadyExists = true
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 디렉토리 생성 (존재하지 않으면)
|
||||
string directory = Path.GetDirectoryName(config.DatabaseFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 디렉토리 생성: {directory}");
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// 3. SQLite 파일은 첫 연결 시 자동 생성됨
|
||||
// 여기서는 빈 파일을 생성하지 않고, 연결 시 자동 생성되도록 함
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 준비 완료: {config.DatabaseFilePath}");
|
||||
ToolkitLogger.Log("DatabaseCreator", "첫 연결 시 자동으로 생성됩니다.");
|
||||
|
||||
await UniTask.Yield(cancellationToken);
|
||||
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Database ready to be created: {config.DatabaseFilePath}",
|
||||
AlreadyExists = false
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseCreator", $" 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
AlreadyExists = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 삭제 (개발/테스트용)
|
||||
/// </summary>
|
||||
public async UniTask<bool> DeleteDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseCreator", $" 데이터베이스 파일이 존재하지 않습니다: {config.DatabaseFilePath}");
|
||||
return true;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 삭제: {config.DatabaseFilePath}");
|
||||
File.Delete(config.DatabaseFilePath);
|
||||
|
||||
// WAL 파일도 삭제 (있으면)
|
||||
string walFile = config.DatabaseFilePath + "-wal";
|
||||
string shmFile = config.DatabaseFilePath + "-shm";
|
||||
|
||||
if (File.Exists(walFile))
|
||||
{
|
||||
File.Delete(walFile);
|
||||
ToolkitLogger.Log("DatabaseCreator", $" WAL 파일 삭제: {walFile}");
|
||||
}
|
||||
|
||||
if (File.Exists(shmFile))
|
||||
{
|
||||
File.Delete(shmFile);
|
||||
ToolkitLogger.Log("DatabaseCreator", $" SHM 파일 삭제: {shmFile}");
|
||||
}
|
||||
|
||||
await UniTask.Yield(cancellationToken);
|
||||
|
||||
ToolkitLogger.Log("DatabaseCreator", "데이터베이스 삭제 완료.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseCreator", $" 삭제 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 정보 조회
|
||||
/// </summary>
|
||||
public DatabaseFileInfo GetDatabaseInfo(DatabaseConfig config)
|
||||
{
|
||||
if (!File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
return new DatabaseFileInfo
|
||||
{
|
||||
Exists = false,
|
||||
FilePath = config.DatabaseFilePath
|
||||
};
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(config.DatabaseFilePath);
|
||||
|
||||
return new DatabaseFileInfo
|
||||
{
|
||||
Exists = true,
|
||||
FilePath = config.DatabaseFilePath,
|
||||
FileSize = fileInfo.Length,
|
||||
CreatedTime = fileInfo.CreationTime,
|
||||
ModifiedTime = fileInfo.LastWriteTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 데이터베이스 생성 결과
|
||||
/// </summary>
|
||||
public struct DatabaseCreationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
public bool AlreadyExists;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 정보
|
||||
/// </summary>
|
||||
public struct DatabaseFileInfo
|
||||
{
|
||||
public bool Exists;
|
||||
public string FilePath;
|
||||
public long FileSize;
|
||||
public DateTime CreatedTime;
|
||||
public DateTime ModifiedTime;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (!Exists)
|
||||
{
|
||||
return $"[DatabaseFileInfo] File does not exist: {FilePath}";
|
||||
}
|
||||
|
||||
return $"[DatabaseFileInfo]\n" +
|
||||
$" Path: {FilePath}\n" +
|
||||
$" Size: {FileSize / 1024.0:F2} KB\n" +
|
||||
$" Created: {CreatedTime}\n" +
|
||||
$" Modified: {ModifiedTime}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dea57a163338a004098266d197d522dd
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// Database 원클릭 자동 설치 마법사
|
||||
/// SQLite - 설치 불필요, DB 파일 생성 → 마이그레이션
|
||||
/// </summary>
|
||||
public class DatabaseSetupWizard
|
||||
{
|
||||
#region Setup Steps
|
||||
public enum SetupStep
|
||||
{
|
||||
NotStarted,
|
||||
PreparingDatabase,
|
||||
RunningMigrations,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private SetupStep currentStep = SetupStep.NotStarted;
|
||||
private string statusMessage = "";
|
||||
private string errorMessage = "";
|
||||
private bool isRunning = false;
|
||||
private float progress = 0f;
|
||||
|
||||
public SetupStep CurrentStep => currentStep;
|
||||
public string StatusMessage => statusMessage;
|
||||
public string ErrorMessage => errorMessage;
|
||||
public bool IsRunning => isRunning;
|
||||
public float Progress => progress;
|
||||
#endregion
|
||||
|
||||
#region Dependencies
|
||||
private readonly DatabaseCreator databaseCreator;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public DatabaseSetupWizard()
|
||||
{
|
||||
databaseCreator = new DatabaseCreator();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Main Setup Flow
|
||||
/// <summary>
|
||||
/// 자동 설치 시작
|
||||
/// </summary>
|
||||
public async UniTask<SetupResult> RunSetupAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
return new SetupResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Setup already in progress."
|
||||
};
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentStep = SetupStep.NotStarted;
|
||||
errorMessage = "";
|
||||
progress = 0f;
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: 데이터베이스 파일 준비
|
||||
if (!await PrepareDatabaseAsync(config, cancellationToken))
|
||||
{
|
||||
return CreateFailureResult("데이터베이스 준비 실패.");
|
||||
}
|
||||
|
||||
// Step 2: 마이그레이션 실행
|
||||
if (!await RunMigrationsAsync(config, cancellationToken))
|
||||
{
|
||||
return CreateFailureResult("마이그레이션 실행 실패.");
|
||||
}
|
||||
|
||||
// 완료
|
||||
currentStep = SetupStep.Completed;
|
||||
statusMessage = "데이터베이스 설치 완료!";
|
||||
progress = 1f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "설치 완료!");
|
||||
|
||||
return new SetupResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Database setup completed successfully!"
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseSetupWizard", "설치가 취소되었습니다.");
|
||||
currentStep = SetupStep.Failed;
|
||||
return CreateFailureResult("설치가 취소되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" 설치 중 예외 발생: {ex.Message}");
|
||||
currentStep = SetupStep.Failed;
|
||||
return CreateFailureResult($"예외 발생: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Step Implementations
|
||||
private async UniTask<bool> PrepareDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
currentStep = SetupStep.PreparingDatabase;
|
||||
statusMessage = "데이터베이스 파일 준비 중...";
|
||||
progress = 0.3f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "데이터베이스 파일 준비 중...");
|
||||
|
||||
var result = await databaseCreator.CreateDatabaseAsync(config, cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.AlreadyExists)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일 준비 완료: {config.DatabaseFilePath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = $"데이터베이스 준비 실패: {result.ErrorMessage}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<bool> RunMigrationsAsync(DatabaseConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
currentStep = SetupStep.RunningMigrations;
|
||||
statusMessage = "마이그레이션 실행 중...";
|
||||
progress = 0.6f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 시작...");
|
||||
|
||||
try
|
||||
{
|
||||
// DatabaseManager 초기화 (InitializeAsync가 자동으로 마이그레이션도 실행함)
|
||||
var initResult = await DatabaseManager.Instance.InitializeAsync(config);
|
||||
if (!initResult.Success)
|
||||
{
|
||||
errorMessage = $"DatabaseManager 초기화 실패: {initResult.ErrorMessage}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 마이그레이션은 InitializeAsync에서 자동으로 실행됨
|
||||
// 중복 실행을 방지하기 위해 별도 호출하지 않음
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 완료 (DatabaseManager.InitializeAsync에서 실행됨)");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"마이그레이션 중 예외 발생: {ex.Message}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private SetupResult CreateFailureResult(string error)
|
||||
{
|
||||
currentStep = SetupStep.Failed;
|
||||
errorMessage = error;
|
||||
progress = 0f;
|
||||
|
||||
return new SetupResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = error
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
/// <summary>
|
||||
/// 상태 초기화
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
currentStep = SetupStep.NotStarted;
|
||||
statusMessage = "";
|
||||
errorMessage = "";
|
||||
isRunning = false;
|
||||
progress = 0f;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Struct
|
||||
/// <summary>
|
||||
/// Setup 결과
|
||||
/// </summary>
|
||||
public struct SetupResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64255a616a004d548b7250bb0302633f
|
||||
866
skills/assets/unity-package/Editor/Database/SyncManager.cs
Normal file
866
skills/assets/unity-package/Editor/Database/SyncManager.cs
Normal file
@@ -0,0 +1,866 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Runtime;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity ↔ PostgreSQL 실시간 동기화 관리자
|
||||
/// Phase 1: 기본 동기화 프레임워크
|
||||
/// Phase 2+: GameObject/Component 실시간 추적, 배치 업데이트
|
||||
/// </summary>
|
||||
public class SyncManager : IDisposable
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseManager databaseManager;
|
||||
private bool isRunning = false;
|
||||
private bool isDisposed = false;
|
||||
private CancellationTokenSource syncCts;
|
||||
|
||||
// 동기화 설정
|
||||
private const int SyncIntervalMilliseconds = 1000; // 1초마다 동기화
|
||||
private const int BatchSize = 500; // 배치당 최대 500개 객체
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 동기화 실행 중 여부
|
||||
/// </summary>
|
||||
public bool IsRunning => isRunning;
|
||||
|
||||
/// <summary>
|
||||
/// 마지막 동기화 시간
|
||||
/// </summary>
|
||||
public DateTime LastSyncTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 성공 횟수
|
||||
/// </summary>
|
||||
public int SuccessfulSyncCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 실패 횟수
|
||||
/// </summary>
|
||||
public int FailedSyncCount { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public SyncManager(DatabaseManager databaseManager)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
LastSyncTime = DateTime.MinValue;
|
||||
SuccessfulSyncCount = 0;
|
||||
FailedSyncCount = 0;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "생성 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Sync Control
|
||||
/// <summary>
|
||||
/// 동기화 시작
|
||||
/// </summary>
|
||||
public void StartSync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (isRunning)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "이미 동기화가 실행 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "DatabaseManager가 초기화되지 않았거나 연결되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 시작...");
|
||||
|
||||
syncCts = new CancellationTokenSource();
|
||||
isRunning = true;
|
||||
|
||||
// 백그라운드 동기화 루프 시작 (UniTask)
|
||||
RunSyncLoopAsync(syncCts.Token).Forget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 중지
|
||||
/// </summary>
|
||||
public void StopSync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 중지 중...");
|
||||
|
||||
syncCts?.Cancel();
|
||||
syncCts?.Dispose();
|
||||
syncCts = null;
|
||||
|
||||
isRunning = false;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 중지 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Sync Loop
|
||||
/// <summary>
|
||||
/// 백그라운드 동기화 루프 (UniTask)
|
||||
/// </summary>
|
||||
private async UniTaskVoid RunSyncLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프 시작.");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// 동기화 수행
|
||||
await PerformSyncAsync(cancellationToken);
|
||||
|
||||
// 대기 (1초)
|
||||
await UniTask.Delay(SyncIntervalMilliseconds, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프가 취소되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 동기화 루프 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프 종료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 동기화 수행 (모든 로드된 씬)
|
||||
/// </summary>
|
||||
private async UniTask PerformSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 단순 연결 테스트만 수행
|
||||
// Phase 2: GameObject 변경 감지 및 배치 업데이트
|
||||
|
||||
bool isConnected = await databaseManager.TestConnectionAsync();
|
||||
if (!isConnected)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "데이터베이스 연결이 끊어졌습니다.");
|
||||
FailedSyncCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: 모든 로드된 씬에 대해 GameObject 변경 감지 및 배치 업데이트
|
||||
int sceneCount = SceneManager.sceneCount;
|
||||
if (sceneCount == 0)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "로드된 씬이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 로드된 씬 순회
|
||||
for (int i = 0; i < sceneCount; i++)
|
||||
{
|
||||
var scene = SceneManager.GetSceneAt(i);
|
||||
|
||||
// 로드되지 않은 씬은 건너뛰기
|
||||
if (!scene.isLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Unity Scene에서 모든 GameObject 수집
|
||||
var allGameObjects = CollectAllGameObjects(scene);
|
||||
if (allGameObjects.Count == 0)
|
||||
{
|
||||
continue; // 빈 씬은 건너뛰기
|
||||
}
|
||||
|
||||
// 2. DB에서 현재 씬의 GameObject 목록 가져오기
|
||||
Dictionary<string, DbGameObject> dbGameObjects;
|
||||
try
|
||||
{
|
||||
dbGameObjects = await GetDatabaseGameObjectsAsync(scene, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" DB 연결 실패로 씬 '{scene.name}' 동기화 건너뜀: {ex.Message}");
|
||||
continue; // 이 씬은 건너뛰고 다음 씬 처리
|
||||
}
|
||||
|
||||
// 3. 변경 감지
|
||||
var changes = DetectChanges(allGameObjects, dbGameObjects);
|
||||
|
||||
// 4. 배치 업데이트 실행
|
||||
if (changes.Updated.Count > 0)
|
||||
{
|
||||
await BatchUpdateGameObjectsAsync(changes.Updated, cancellationToken);
|
||||
}
|
||||
|
||||
if (changes.Inserted.Count > 0)
|
||||
{
|
||||
await BatchInsertGameObjectsAsync(scene, changes.Inserted, cancellationToken);
|
||||
}
|
||||
|
||||
if (changes.Deleted.Count > 0)
|
||||
{
|
||||
await BatchMarkDeletedAsync(changes.Deleted, cancellationToken);
|
||||
}
|
||||
|
||||
// 취소 확인
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
LastSyncTime = DateTime.UtcNow;
|
||||
SuccessfulSyncCount++;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // 취소는 상위로 전파
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 동기화 중 예외 발생: {ex.Message}");
|
||||
FailedSyncCount++;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Manual Sync
|
||||
/// <summary>
|
||||
/// 수동 동기화 (즉시 실행)
|
||||
/// </summary>
|
||||
public async UniTask<SyncResult> SyncNowAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Database not initialized or not connected."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "수동 동기화 시작...");
|
||||
|
||||
await PerformSyncAsync(cancellationToken);
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "수동 동기화 완료.");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Manual sync completed successfully."
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "수동 동기화가 취소되었습니다.");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Manual sync was canceled."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 수동 동기화 중 예외 발생: {ex.Message}");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Batch Operations (Phase 2+)
|
||||
/// <summary>
|
||||
/// GameObject 배치 업데이트
|
||||
/// </summary>
|
||||
/// <param name="gameObjects">업데이트할 GameObject 목록</param>
|
||||
public async UniTask<int> BatchUpdateGameObjectsAsync(List<GameObject> gameObjects, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (gameObjects == null || gameObjects.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
|
||||
// 배치 크기로 나누어 처리
|
||||
for (int i = 0; i < gameObjects.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
|
||||
var batch = gameObjects.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var obj in batch)
|
||||
{
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
int instanceId = obj.GetInstanceID();
|
||||
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
|
||||
var sql = @"
|
||||
UPDATE gameobjects
|
||||
SET object_name = ?,
|
||||
parent_id = ?,
|
||||
tag = ?,
|
||||
layer = ?,
|
||||
is_active = ?,
|
||||
is_static = ?,
|
||||
instance_id = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE guid = ?
|
||||
";
|
||||
|
||||
connection.Execute(sql, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic, instanceId, guid);
|
||||
}
|
||||
});
|
||||
|
||||
updatedCount += batchCount;
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 업데이트 완료: {batchCount}개 GameObject");
|
||||
|
||||
// 취소 확인
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component 배치 업데이트 (Phase 2에서 구현)
|
||||
/// </summary>
|
||||
public async UniTask<int> BatchUpdateComponentsAsync(List<Component> components, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (components == null || components.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// TODO Phase 2: Component 배치 업데이트 구현
|
||||
|
||||
await UniTask.Yield();
|
||||
return 0;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods (Phase 2)
|
||||
/// <summary>
|
||||
/// 트랜잭션을 안전하게 실행하는 헬퍼 메서드
|
||||
/// </summary>
|
||||
private void ExecuteInTransaction(SQLite.SQLiteConnection connection, Action action)
|
||||
{
|
||||
// SQLite는 중첩 트랜잭션을 지원하지 않으므로 try-catch로 감지
|
||||
bool transactionStarted = false;
|
||||
try
|
||||
{
|
||||
connection.BeginTransaction();
|
||||
transactionStarted = true;
|
||||
|
||||
action();
|
||||
|
||||
connection.Commit();
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("transaction") || ex.Message.Contains("Transaction"))
|
||||
{
|
||||
// 이미 트랜잭션이 시작된 경우, 그냥 액션만 실행
|
||||
ToolkitLogger.LogWarning("SyncManager", $" Transaction already started, executing without nested transaction: {ex.Message}");
|
||||
if (transactionStarted)
|
||||
{
|
||||
connection.Rollback();
|
||||
}
|
||||
action();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transactionStarted)
|
||||
{
|
||||
connection.Rollback();
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity Scene에서 모든 GameObject 재귀적으로 수집
|
||||
/// </summary>
|
||||
private List<GameObject> CollectAllGameObjects(Scene scene)
|
||||
{
|
||||
var result = new List<GameObject>();
|
||||
var rootObjects = scene.GetRootGameObjects();
|
||||
|
||||
foreach (var root in rootObjects)
|
||||
{
|
||||
CollectGameObjectsRecursive(root, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject를 재귀적으로 수집하는 헬퍼 메서드
|
||||
/// </summary>
|
||||
private void CollectGameObjectsRecursive(GameObject obj, List<GameObject> list)
|
||||
{
|
||||
list.Add(obj);
|
||||
for (int i = 0; i < obj.transform.childCount; i++)
|
||||
{
|
||||
CollectGameObjectsRecursive(obj.transform.GetChild(i).gameObject, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DB에서 현재 씬의 GameObject 목록 가져오기 (GUID 기반)
|
||||
/// </summary>
|
||||
private async UniTask<Dictionary<string, DbGameObject>> GetDatabaseGameObjectsAsync(Scene scene, CancellationToken cancellationToken)
|
||||
{
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
throw new InvalidOperationException("Database connection is not available");
|
||||
}
|
||||
|
||||
// 1. scene_id 가져오기
|
||||
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
|
||||
if (!sceneIds.Any())
|
||||
{
|
||||
// Scene이 DB에 없는 것은 정상 (첫 동기화)
|
||||
ToolkitLogger.Log("SyncManager", $" Scene '{scene.name}'이 DB에 없습니다 (첫 동기화).");
|
||||
return new Dictionary<string, DbGameObject>();
|
||||
}
|
||||
|
||||
int sceneId = sceneIds.First().scene_id;
|
||||
|
||||
// 2. GameObject 목록 가져오기 (guid 포함)
|
||||
var sql = @"
|
||||
SELECT object_id, instance_id, guid, object_name, parent_id, tag, layer, is_active, is_static, is_deleted
|
||||
FROM gameobjects
|
||||
WHERE scene_id = ? AND is_deleted = 0
|
||||
";
|
||||
|
||||
var dbObjects = connection.Query<DbGameObject>(sql, sceneId);
|
||||
|
||||
// Dictionary로 변환 (guid를 키로 사용, guid가 null인 경우 instance_id를 fallback으로 사용)
|
||||
var result = new Dictionary<string, DbGameObject>();
|
||||
foreach (var obj in dbObjects)
|
||||
{
|
||||
string key = !string.IsNullOrEmpty(obj.guid) ? obj.guid : $"instance_{obj.instance_id}";
|
||||
result[key] = obj;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scene ID를 가져오기 위한 레코드 클래스
|
||||
/// </summary>
|
||||
private class SceneIdRecord
|
||||
{
|
||||
public int scene_id { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Change Detection (Phase 2)
|
||||
/// <summary>
|
||||
/// GameObject에 GameObjectGuid 컴포넌트 확보 (없으면 추가)
|
||||
/// </summary>
|
||||
private GameObjectGuid EnsureGameObjectGuid(GameObject obj)
|
||||
{
|
||||
var guidComp = obj.GetComponent<GameObjectGuid>();
|
||||
if (guidComp == null)
|
||||
{
|
||||
guidComp = obj.AddComponent<GameObjectGuid>();
|
||||
}
|
||||
return guidComp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity GameObject와 DB GameObject 비교하여 변경사항 감지 (GUID 기반)
|
||||
/// </summary>
|
||||
private GameObjectChangeSet DetectChanges(List<GameObject> unityObjects, Dictionary<string, DbGameObject> dbObjects)
|
||||
{
|
||||
var changeSet = new GameObjectChangeSet
|
||||
{
|
||||
Updated = new List<GameObject>(),
|
||||
Inserted = new List<GameObject>(),
|
||||
Deleted = new List<int>()
|
||||
};
|
||||
|
||||
// Unity에 있는 객체 확인 (GUID 기반)
|
||||
var processedGuids = new HashSet<string>();
|
||||
|
||||
foreach (var obj in unityObjects)
|
||||
{
|
||||
// GameObjectGuid 컴포넌트 확보
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
|
||||
processedGuids.Add(guid);
|
||||
|
||||
if (dbObjects.TryGetValue(guid, out var dbObj))
|
||||
{
|
||||
// DB에 존재: 변경 여부 확인
|
||||
if (HasChanged(obj, dbObj))
|
||||
{
|
||||
changeSet.Updated.Add(obj);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// DB에 없음: 새로운 객체
|
||||
changeSet.Inserted.Add(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// DB에만 있고 Unity에 없는 객체 확인 (삭제된 객체)
|
||||
foreach (var kvp in dbObjects)
|
||||
{
|
||||
if (!processedGuids.Contains(kvp.Key))
|
||||
{
|
||||
changeSet.Deleted.Add(kvp.Value.object_id);
|
||||
}
|
||||
}
|
||||
|
||||
return changeSet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject가 DB 레코드와 비교하여 변경되었는지 확인
|
||||
/// </summary>
|
||||
private bool HasChanged(GameObject obj, DbGameObject dbObj)
|
||||
{
|
||||
// 이름 변경
|
||||
if (obj.name != dbObj.object_name)
|
||||
return true;
|
||||
|
||||
// Parent 변경
|
||||
int? currentParentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
if (currentParentId != dbObj.parent_id)
|
||||
return true;
|
||||
|
||||
// Tag 변경
|
||||
if (obj.tag != dbObj.tag)
|
||||
return true;
|
||||
|
||||
// Layer 변경
|
||||
if (obj.layer != dbObj.layer)
|
||||
return true;
|
||||
|
||||
// Active 상태 변경
|
||||
if (obj.activeSelf != dbObj.is_active)
|
||||
return true;
|
||||
|
||||
// Static 플래그 변경
|
||||
if (obj.isStatic != dbObj.is_static)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Batch Insert/Delete (Phase 2)
|
||||
/// <summary>
|
||||
/// GameObject 배치 삽입
|
||||
/// </summary>
|
||||
private async UniTask BatchInsertGameObjectsAsync(Scene scene, List<GameObject> gameObjects, CancellationToken cancellationToken)
|
||||
{
|
||||
if (gameObjects == null || gameObjects.Count == 0)
|
||||
return;
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. scene_id 가져오기 (또는 생성)
|
||||
int sceneId = EnsureSceneRecord(connection, scene);
|
||||
|
||||
// 2. 배치 INSERT
|
||||
for (int i = 0; i < gameObjects.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
|
||||
var batch = gameObjects.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var obj in batch)
|
||||
{
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
int instanceId = obj.GetInstanceID();
|
||||
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
|
||||
var sql = @"
|
||||
INSERT INTO gameobjects (guid, instance_id, scene_id, object_name, parent_id, tag, layer, is_active, is_static, is_deleted, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
";
|
||||
|
||||
connection.Execute(sql, guid, instanceId, sceneId, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic);
|
||||
}
|
||||
});
|
||||
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 삽입 완료: {batchCount}개 GameObject");
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject 배치 삭제 (soft delete)
|
||||
/// </summary>
|
||||
private async UniTask BatchMarkDeletedAsync(List<int> objectIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (objectIds == null || objectIds.Count == 0)
|
||||
return;
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 배치 UPDATE (soft delete)
|
||||
for (int i = 0; i < objectIds.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, objectIds.Count - i);
|
||||
var batch = objectIds.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var objectId in batch)
|
||||
{
|
||||
var sql = @"
|
||||
UPDATE gameobjects
|
||||
SET is_deleted = 1, updated_at = datetime('now', 'localtime')
|
||||
WHERE object_id = ?
|
||||
";
|
||||
|
||||
connection.Execute(sql, objectId);
|
||||
}
|
||||
});
|
||||
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 삭제 완료: {batchCount}개 GameObject");
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scene 레코드가 DB에 존재하는지 확인하고 없으면 생성
|
||||
/// </summary>
|
||||
private int EnsureSceneRecord(SQLite.SQLiteConnection connection, Scene scene)
|
||||
{
|
||||
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
|
||||
if (sceneIds.Any())
|
||||
{
|
||||
return sceneIds.First().scene_id;
|
||||
}
|
||||
|
||||
// Scene 레코드 생성
|
||||
var insertSql = @"
|
||||
INSERT INTO scenes (scene_path, scene_name, created_at, updated_at)
|
||||
VALUES (?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
";
|
||||
connection.Execute(insertSql, scene.path, scene.name);
|
||||
|
||||
// 생성된 scene_id 반환
|
||||
var newSceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
return newSceneIds.First().scene_id;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Health Check
|
||||
/// <summary>
|
||||
/// SyncManager 상태 정보
|
||||
/// </summary>
|
||||
public SyncHealthStatus GetHealthStatus()
|
||||
{
|
||||
return new SyncHealthStatus
|
||||
{
|
||||
IsRunning = isRunning,
|
||||
LastSyncTime = LastSyncTime,
|
||||
SuccessfulSyncCount = SuccessfulSyncCount,
|
||||
FailedSyncCount = FailedSyncCount,
|
||||
SyncIntervalMs = SyncIntervalMilliseconds,
|
||||
BatchSize = BatchSize
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Disposal
|
||||
public void Dispose()
|
||||
{
|
||||
if (isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopSync();
|
||||
isDisposed = true;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "Disposed.");
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (isDisposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SyncManager));
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 동기화 결과
|
||||
/// </summary>
|
||||
public struct SyncResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SyncManager 상태
|
||||
/// </summary>
|
||||
public struct SyncHealthStatus
|
||||
{
|
||||
public bool IsRunning;
|
||||
public DateTime LastSyncTime;
|
||||
public int SuccessfulSyncCount;
|
||||
public int FailedSyncCount;
|
||||
public int SyncIntervalMs;
|
||||
public int BatchSize;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[SyncHealthStatus]\n" +
|
||||
$" Running: {IsRunning}\n" +
|
||||
$" Last Sync: {LastSyncTime:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$" Success: {SuccessfulSyncCount}, Failed: {FailedSyncCount}\n" +
|
||||
$" Interval: {SyncIntervalMs}ms, Batch: {BatchSize}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DB GameObject 레코드 (SQLite-net ORM용)
|
||||
/// </summary>
|
||||
public class DbGameObject
|
||||
{
|
||||
public int object_id { get; set; }
|
||||
public int instance_id { get; set; }
|
||||
public string guid { get; set; }
|
||||
public string object_name { get; set; }
|
||||
public int? parent_id { get; set; }
|
||||
public string tag { get; set; }
|
||||
public int layer { get; set; }
|
||||
public bool is_active { get; set; }
|
||||
public bool is_static { get; set; }
|
||||
public bool is_deleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject 변경사항 집합
|
||||
/// </summary>
|
||||
public class GameObjectChangeSet
|
||||
{
|
||||
public List<GameObject> Updated { get; set; }
|
||||
public List<GameObject> Inserted { get; set; }
|
||||
public List<int> Deleted { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97a6b4ca15448704ab5758aa7642bca3
|
||||
371
skills/assets/unity-package/Editor/DatabaseStatusWindow.cs
Normal file
371
skills/assets/unity-package/Editor/DatabaseStatusWindow.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Database 상태 및 컨트롤을 표시하는 별도 윈도우
|
||||
/// </summary>
|
||||
public class DatabaseStatusWindow : EditorWindow
|
||||
{
|
||||
#region Fields
|
||||
private EditorServerWindow parentWindow;
|
||||
|
||||
// Data binding source
|
||||
private EditorServerWindowData windowData = new EditorServerWindowData();
|
||||
|
||||
// UI Elements - Status
|
||||
private VisualElement dbStatusIndicator;
|
||||
private Label dbStatusLabel;
|
||||
private Label dbFileExistsLabel;
|
||||
private Label dbSyncStatusLabel;
|
||||
|
||||
// UI Elements - Buttons
|
||||
private Button dbTestButton;
|
||||
private Button dbConnectButton;
|
||||
private Button dbDisconnectButton;
|
||||
private Button dbMigrateButton;
|
||||
private Button dbSyncToggleButton;
|
||||
|
||||
// UI Elements - Command History
|
||||
private Label dbUndoCount;
|
||||
private Label dbRedoCount;
|
||||
private Button dbUndoButton;
|
||||
private Button dbRedoButton;
|
||||
private Button dbClearHistoryButton;
|
||||
|
||||
// UI Elements - Messages
|
||||
private HelpBox dbErrorHelp;
|
||||
private HelpBox dbSuccessHelp;
|
||||
#endregion
|
||||
|
||||
#region Window Management
|
||||
/// <summary>
|
||||
/// 윈도우 열기 (팩토리 메서드)
|
||||
/// </summary>
|
||||
public static DatabaseStatusWindow Open(EditorServerWindow parentWindow)
|
||||
{
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"Open 시작, parentWindow: {(parentWindow != null ? "존재" : "null")}");
|
||||
|
||||
var window = GetWindow<DatabaseStatusWindow>("Database Status & Controls");
|
||||
window.minSize = new Vector2(400, 500);
|
||||
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"GetWindow 완료, parentWindow 설정 전: {(window.parentWindow != null ? "존재" : "null")}");
|
||||
|
||||
window.parentWindow = parentWindow;
|
||||
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"parentWindow 설정 완료: {(window.parentWindow != null ? "존재" : "null")}");
|
||||
|
||||
window.Show();
|
||||
|
||||
// CreateGUI()가 parentWindow 설정 전에 실행되었을 수 있으므로 다시 업데이트
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", "Open에서 UpdateUI() 호출");
|
||||
window.UpdateUI();
|
||||
|
||||
return window;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void CreateGUI()
|
||||
{
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"CreateGUI 시작, parentWindow: {(parentWindow != null ? "존재" : "null")}");
|
||||
|
||||
// Load UXML
|
||||
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
|
||||
"Packages/com.devgom.unity-editor-toolkit/Editor/DatabaseStatusWindow.uxml");
|
||||
|
||||
if (visualTree == null)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseStatusWindow", "UXML file not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
visualTree.CloneTree(rootVisualElement);
|
||||
|
||||
// Set data binding source
|
||||
rootVisualElement.dataSource = windowData;
|
||||
|
||||
// Load USS (EditorServerWindow.uss 재사용)
|
||||
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
|
||||
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uss");
|
||||
|
||||
if (styleSheet != null)
|
||||
{
|
||||
rootVisualElement.styleSheets.Add(styleSheet);
|
||||
}
|
||||
|
||||
// Query UI elements
|
||||
QueryUIElements();
|
||||
|
||||
// Register events
|
||||
RegisterEvents();
|
||||
|
||||
// Initial UI update
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", "CreateGUI에서 UpdateUI() 호출");
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// Cleanup
|
||||
UnregisterEvents();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Query
|
||||
private void QueryUIElements()
|
||||
{
|
||||
var root = rootVisualElement;
|
||||
|
||||
// Status
|
||||
dbStatusIndicator = root.Q<VisualElement>("db-status-indicator");
|
||||
dbStatusLabel = root.Q<Label>("db-status-label");
|
||||
dbFileExistsLabel = root.Q<Label>("db-file-exists-label");
|
||||
dbSyncStatusLabel = root.Q<Label>("db-sync-status-label");
|
||||
|
||||
// Buttons
|
||||
dbTestButton = root.Q<Button>("db-test-button");
|
||||
dbConnectButton = root.Q<Button>("db-connect-button");
|
||||
dbDisconnectButton = root.Q<Button>("db-disconnect-button");
|
||||
dbMigrateButton = root.Q<Button>("db-migrate-button");
|
||||
dbSyncToggleButton = root.Q<Button>("db-sync-toggle-button");
|
||||
|
||||
// Command History
|
||||
dbUndoCount = root.Q<Label>("db-undo-count");
|
||||
dbRedoCount = root.Q<Label>("db-redo-count");
|
||||
dbUndoButton = root.Q<Button>("db-undo-button");
|
||||
dbRedoButton = root.Q<Button>("db-redo-button");
|
||||
dbClearHistoryButton = root.Q<Button>("db-clear-history-button");
|
||||
|
||||
// Messages
|
||||
dbErrorHelp = root.Q<HelpBox>("db-error-help");
|
||||
dbSuccessHelp = root.Q<HelpBox>("db-success-help");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
private void RegisterEvents()
|
||||
{
|
||||
if (dbTestButton != null)
|
||||
dbTestButton.clicked += OnTestConnectionClicked;
|
||||
|
||||
if (dbConnectButton != null)
|
||||
dbConnectButton.clicked += OnConnectClicked;
|
||||
|
||||
if (dbDisconnectButton != null)
|
||||
dbDisconnectButton.clicked += OnDisconnectClicked;
|
||||
|
||||
if (dbMigrateButton != null)
|
||||
dbMigrateButton.clicked += OnRunMigrationsClicked;
|
||||
|
||||
if (dbSyncToggleButton != null)
|
||||
dbSyncToggleButton.clicked += OnSyncToggleClicked;
|
||||
|
||||
if (dbUndoButton != null)
|
||||
dbUndoButton.clicked += OnUndoClicked;
|
||||
|
||||
if (dbRedoButton != null)
|
||||
dbRedoButton.clicked += OnRedoClicked;
|
||||
|
||||
if (dbClearHistoryButton != null)
|
||||
dbClearHistoryButton.clicked += OnClearHistoryClicked;
|
||||
}
|
||||
|
||||
private void UnregisterEvents()
|
||||
{
|
||||
if (dbTestButton != null)
|
||||
dbTestButton.clicked -= OnTestConnectionClicked;
|
||||
|
||||
if (dbConnectButton != null)
|
||||
dbConnectButton.clicked -= OnConnectClicked;
|
||||
|
||||
if (dbDisconnectButton != null)
|
||||
dbDisconnectButton.clicked -= OnDisconnectClicked;
|
||||
|
||||
if (dbMigrateButton != null)
|
||||
dbMigrateButton.clicked -= OnRunMigrationsClicked;
|
||||
|
||||
if (dbSyncToggleButton != null)
|
||||
dbSyncToggleButton.clicked -= OnSyncToggleClicked;
|
||||
|
||||
if (dbUndoButton != null)
|
||||
dbUndoButton.clicked -= OnUndoClicked;
|
||||
|
||||
if (dbRedoButton != null)
|
||||
dbRedoButton.clicked -= OnRedoClicked;
|
||||
|
||||
if (dbClearHistoryButton != null)
|
||||
dbClearHistoryButton.clicked -= OnClearHistoryClicked;
|
||||
}
|
||||
|
||||
private void OnTestConnectionClicked()
|
||||
{
|
||||
parentWindow?.TestConnection();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnConnectClicked()
|
||||
{
|
||||
parentWindow?.Connect();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnDisconnectClicked()
|
||||
{
|
||||
parentWindow?.Disconnect();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnRunMigrationsClicked()
|
||||
{
|
||||
parentWindow?.RunMigrations();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnSyncToggleClicked()
|
||||
{
|
||||
parentWindow?.ToggleSync();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnUndoClicked()
|
||||
{
|
||||
parentWindow?.Undo();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnRedoClicked()
|
||||
{
|
||||
parentWindow?.Redo();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnClearHistoryClicked()
|
||||
{
|
||||
parentWindow?.ClearHistory();
|
||||
UpdateUI();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Update
|
||||
/// <summary>
|
||||
/// UI 상태 업데이트
|
||||
/// </summary>
|
||||
public void UpdateUI()
|
||||
{
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"UpdateUI 시작, parentWindow: {(parentWindow != null ? "존재" : "null")}");
|
||||
|
||||
if (parentWindow == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseStatusWindow", "parentWindow가 null이므로 업데이트 중단");
|
||||
return;
|
||||
}
|
||||
|
||||
// Status
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", "상태 업데이트 시작");
|
||||
UpdateConnectionStatus();
|
||||
UpdateDatabaseFileStatus();
|
||||
UpdateSyncStatus();
|
||||
UpdateCommandHistory();
|
||||
UpdateMessages();
|
||||
ToolkitLogger.LogDebug("DatabaseStatusWindow", "상태 업데이트 완료");
|
||||
}
|
||||
|
||||
private void UpdateConnectionStatus()
|
||||
{
|
||||
bool isConnected = parentWindow.IsConnected;
|
||||
|
||||
// Update data (UI auto-updates via data binding)
|
||||
windowData.DbIsConnected = isConnected;
|
||||
|
||||
// Update status indicator classes (CSS classes cannot be bound)
|
||||
if (dbStatusIndicator != null)
|
||||
{
|
||||
dbStatusIndicator.RemoveFromClassList("status-stopped");
|
||||
dbStatusIndicator.RemoveFromClassList("status-running");
|
||||
dbStatusIndicator.AddToClassList(isConnected ? "status-running" : "status-stopped");
|
||||
}
|
||||
|
||||
// Update button states (not bound to data)
|
||||
dbConnectButton?.SetEnabled(!isConnected);
|
||||
dbDisconnectButton?.SetEnabled(isConnected);
|
||||
|
||||
if (dbDisconnectButton != null)
|
||||
{
|
||||
dbDisconnectButton.style.display = isConnected ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDatabaseFileStatus()
|
||||
{
|
||||
bool fileExists = parentWindow.DatabaseFileExists();
|
||||
|
||||
// Update data (UI auto-updates via data binding)
|
||||
windowData.DbFileExists = fileExists;
|
||||
}
|
||||
|
||||
private void UpdateSyncStatus()
|
||||
{
|
||||
bool isSyncing = parentWindow.IsSyncing;
|
||||
|
||||
// Update data (UI auto-updates via data binding)
|
||||
windowData.DbIsSyncing = isSyncing;
|
||||
|
||||
// Update button text (not bound to data)
|
||||
if (dbSyncToggleButton != null)
|
||||
{
|
||||
dbSyncToggleButton.text = isSyncing ? "⏹️ Stop Sync" : "🔄 Start Sync";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCommandHistory()
|
||||
{
|
||||
int undoCount = parentWindow.UndoCount;
|
||||
int redoCount = parentWindow.RedoCount;
|
||||
|
||||
// Update data (UI auto-updates via data binding)
|
||||
windowData.DbUndoCount = undoCount;
|
||||
windowData.DbRedoCount = redoCount;
|
||||
|
||||
// Update button states (not bound to data)
|
||||
dbUndoButton?.SetEnabled(undoCount > 0);
|
||||
dbRedoButton?.SetEnabled(redoCount > 0);
|
||||
}
|
||||
|
||||
private void UpdateMessages()
|
||||
{
|
||||
var errorMessage = parentWindow.GetErrorMessage();
|
||||
var successMessage = parentWindow.GetSuccessMessage();
|
||||
|
||||
if (dbErrorHelp != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
dbErrorHelp.text = errorMessage;
|
||||
dbErrorHelp.RemoveFromClassList("hidden");
|
||||
}
|
||||
else
|
||||
{
|
||||
dbErrorHelp.AddToClassList("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSuccessHelp != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
dbSuccessHelp.text = successMessage;
|
||||
dbSuccessHelp.RemoveFromClassList("hidden");
|
||||
}
|
||||
else
|
||||
{
|
||||
dbSuccessHelp.AddToClassList("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7ccf14f7c29c064895231c4f68a2b52
|
||||
81
skills/assets/unity-package/Editor/DatabaseStatusWindow.uxml
Normal file
81
skills/assets/unity-package/Editor/DatabaseStatusWindow.uxml
Normal file
@@ -0,0 +1,81 @@
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
|
||||
<ui:ScrollView mode="Vertical" class="scroll-container">
|
||||
<ui:VisualElement name="root" class="root-container">
|
||||
<!-- Connection Status -->
|
||||
<ui:Label text="📊 Status" class="section-title" />
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="🔌 Connection:" class="label-key" tooltip="SQLite 데이터베이스 연결 상태 Connect 버튼으로 연결" />
|
||||
<ui:VisualElement class="status-indicator-container">
|
||||
<ui:VisualElement name="db-status-indicator" class="status-indicator status-stopped" />
|
||||
<ui:Label name="db-status-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbStatusText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="💾 Database File:" class="label-key" tooltip="데이터베이스 파일 생성 여부 Reinstall Database 버튼으로 생성" />
|
||||
<ui:Label name="db-file-exists-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbFileExistsText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="🔄 Sync Status:" class="label-key" tooltip="실시간 GameObject/Component 동기화 상태 (Phase 2에서 구현 예정)" />
|
||||
<ui:Label name="db-sync-status-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbSyncStatusText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ui:Label text="🎮 Connection Controls" class="section-title" style="margin-top: 15px;" />
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-test-button" text="🔍 Test Connection" class="action-button" tooltip="데이터베이스 연결 테스트 SQLite 버전 정보 확인" />
|
||||
<ui:Button name="db-connect-button" text="🔌 Connect" class="action-button" tooltip="데이터베이스에 연결 Command History 활성화" />
|
||||
<ui:Button name="db-disconnect-button" text="⏸️ Disconnect" class="action-button hidden" tooltip="데이터베이스 연결 해제" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-migrate-button" text="⚙️ Run Migrations" class="primary-button" tooltip="스키마 마이그레이션 실행 테이블/인덱스/트리거 생성" />
|
||||
<ui:Button name="db-sync-toggle-button" text="🔄 Start Sync" class="secondary-button" tooltip="GameObject/Component 실시간 동기화 (Phase 2에서 구현 예정)" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Command History (Undo/Redo) -->
|
||||
<ui:Label text="📜 Command History" class="section-title" style="margin-top: 15px;" />
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="⟲ Undo Stack:" class="label-key" tooltip="실행 취소 가능한 명령 수 데이터베이스에 영구 저장" />
|
||||
<ui:Label name="db-undo-count" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbUndoCountText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="⟳ Redo Stack:" class="label-key" tooltip="다시 실행 가능한 명령 수 Undo 후 사용 가능" />
|
||||
<ui:Label name="db-redo-count" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbRedoCountText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-undo-button" text="⟲ Undo" class="action-button" tooltip="마지막 명령 실행 취소 GameObject/Component 변경 복원" />
|
||||
<ui:Button name="db-redo-button" text="⟳ Redo" class="action-button" tooltip="취소한 명령 다시 실행" />
|
||||
<ui:Button name="db-clear-history-button" text="🗑️ Clear History" class="action-button" tooltip="모든 명령 히스토리 삭제 Undo/Redo 불가능" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<ui:HelpBox name="db-error-help" text="" message-type="Error" class="hidden" />
|
||||
<ui:HelpBox name="db-success-help" text="" message-type="Info" class="hidden" />
|
||||
</ui:VisualElement>
|
||||
</ui:ScrollView>
|
||||
</ui:UXML>
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d457029b6b1b58d47bb7aa38a3897205
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||
542
skills/assets/unity-package/Editor/EditorServerCLIInstaller.cs
Normal file
542
skills/assets/unity-package/Editor/EditorServerCLIInstaller.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles CLI script installation and version management
|
||||
/// </summary>
|
||||
public class EditorServerCLIInstaller
|
||||
{
|
||||
private const int LockFileStaleMinutes = 10;
|
||||
private const int NpmInstallTimeoutSeconds = 30;
|
||||
private const int NpmBuildTimeoutSeconds = 120;
|
||||
private const int NpmInstallExtendedTimeoutSeconds = 300; // 5 minutes for initial install
|
||||
private const int TimestampFutureToleranceMinutes = 1;
|
||||
private const long DefaultRequiredDiskSpaceMB = 500;
|
||||
private const int LockAcquisitionRetryIntervalMs = 500;
|
||||
|
||||
public string PluginVersion { get; private set; }
|
||||
public string LocalCLIVersion { get; private set; }
|
||||
public string HomeCLIVersion { get; private set; }
|
||||
public bool UpdateAvailable { get; private set; }
|
||||
public bool IsInstalling { get; private set; }
|
||||
public string InstallLog { get; private set; } = "";
|
||||
|
||||
private string pluginScriptsPathOverride;
|
||||
|
||||
public EditorServerCLIInstaller(string pathOverride = null)
|
||||
{
|
||||
pluginScriptsPathOverride = pathOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check CLI versions and update availability
|
||||
/// </summary>
|
||||
public void CheckVersion()
|
||||
{
|
||||
PluginVersion = EditorServerPathManager.GetPluginVersion();
|
||||
LocalCLIVersion = EditorServerPathManager.GetLocalCLIVersion();
|
||||
HomeCLIVersion = EditorServerPathManager.GetHomeCLIVersion();
|
||||
// Update available when home CLI version differs from installed local CLI version
|
||||
UpdateAvailable = (HomeCLIVersion != null && LocalCLIVersion != null && HomeCLIVersion != LocalCLIVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if minor version difference (same major.minor, different patch)
|
||||
/// </summary>
|
||||
public static bool IsMinorVersionDifference(string v1, string v2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts1 = v1.Split('.');
|
||||
var parts2 = v2.Split('.');
|
||||
|
||||
if (parts1.Length >= 2 && parts2.Length >= 2)
|
||||
{
|
||||
// Same major and minor version? (only patch different)
|
||||
return parts1[0] == parts2[0] && parts1[1] == parts2[1];
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if installation is in progress
|
||||
/// </summary>
|
||||
public bool IsInstallationInProgress()
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
string lockFile = Path.Combine(projectRoot, ".unity-websocket", ".install.lock");
|
||||
|
||||
if (!File.Exists(lockFile))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if lock is stale
|
||||
if (IsLockStale(lockFile))
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", "Removing stale installation lock");
|
||||
try
|
||||
{
|
||||
File.Delete(lockFile);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogError("CLIInstaller", $"Failed to delete stale lock: {e.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear installation lock file
|
||||
/// </summary>
|
||||
public void ClearInstallationLock()
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
string lockFile = Path.Combine(projectRoot, ".unity-websocket", ".install.lock");
|
||||
|
||||
if (File.Exists(lockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(lockFile);
|
||||
ToolkitLogger.Log("CLIInstaller", "Installation lock cleared");
|
||||
EditorUtility.DisplayDialog("Lock Cleared", "Installation lock has been cleared.\nYou can now retry installation.", "OK");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogError("CLIInstaller", $"Failed to clear lock: {e.Message}");
|
||||
EditorUtility.DisplayDialog("Error", $"Failed to clear lock:\n{e.Message}", "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Install or update CLI scripts
|
||||
/// </summary>
|
||||
public void InstallOrUpdate()
|
||||
{
|
||||
IsInstalling = true;
|
||||
InstallLog = "";
|
||||
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
string outputDir = Path.Combine(projectRoot, ".unity-websocket");
|
||||
string lockFile = Path.Combine(outputDir, ".install.lock");
|
||||
|
||||
try
|
||||
{
|
||||
// Pre-flight checks
|
||||
InstallLog += "[Pre-flight] Running system checks...\n";
|
||||
|
||||
// Check disk space
|
||||
if (!CheckDiskSpace(projectRoot, DefaultRequiredDiskSpaceMB))
|
||||
{
|
||||
bool proceed = EditorUtility.DisplayDialog("Low Disk Space",
|
||||
$"Less than {DefaultRequiredDiskSpaceMB}MB available. Installation may fail.\n\nProceed anyway?",
|
||||
"Yes", "No");
|
||||
|
||||
if (!proceed)
|
||||
{
|
||||
InstallLog += "❌ Installation cancelled by user (low disk space)\n";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
if (!CheckWritePermission(outputDir))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Permission Denied",
|
||||
$"Cannot write to {outputDir}\n\n" +
|
||||
"If using version control:\n" +
|
||||
"• Check out the .unity-websocket folder\n" +
|
||||
"• Or add it to .gitignore/.p4ignore",
|
||||
"OK");
|
||||
InstallLog += "❌ Write permission denied\n";
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire installation lock
|
||||
InstallLog += "[Pre-flight] Acquiring installation lock...\n";
|
||||
string lockError;
|
||||
if (!AcquireLock(lockFile, out lockError))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Installation In Progress", lockError ?? "Cannot acquire lock", "OK");
|
||||
InstallLog += $"❌ {lockError}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
InstallLog += "✓ Pre-flight checks passed\n\n";
|
||||
|
||||
string skillsDir = Path.Combine(outputDir, "skills", "scripts");
|
||||
|
||||
// Step 1: Create output directory
|
||||
InstallLog += "[1/5] Creating output directory...\n";
|
||||
if (!Directory.Exists(outputDir))
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
}
|
||||
|
||||
// Create .gitignore
|
||||
string gitignorePath = Path.Combine(outputDir, ".gitignore");
|
||||
if (!File.Exists(gitignorePath))
|
||||
{
|
||||
File.WriteAllText(gitignorePath, "# Unity WebSocket generated files\n*\n!.gitignore\n");
|
||||
}
|
||||
|
||||
// Step 2: Remove old CLI scripts
|
||||
InstallLog += "[2/5] Removing old CLI scripts...\n";
|
||||
if (Directory.Exists(skillsDir))
|
||||
{
|
||||
Directory.Delete(skillsDir, true);
|
||||
}
|
||||
|
||||
// Step 3: Copy CLI scripts from plugin
|
||||
InstallLog += "[3/5] Copying CLI scripts...\n";
|
||||
string pluginScriptsPath = EditorServerPathManager.FindPluginScriptsPath(pluginScriptsPathOverride);
|
||||
if (string.IsNullOrEmpty(pluginScriptsPath))
|
||||
{
|
||||
InstallLog += "❌ ERROR: Plugin scripts not found!\n";
|
||||
ToolkitLogger.LogError("CLIInstaller", "Plugin scripts path not found");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorServerPathManager.CopyDirectory(pluginScriptsPath, skillsDir);
|
||||
InstallLog += $"✓ Copied from: {pluginScriptsPath}\n";
|
||||
|
||||
// Step 4: npm install
|
||||
InstallLog += "[4/5] Installing dependencies (npm install)...\n";
|
||||
InstallLog += "This may take a minute...\n";
|
||||
|
||||
string npmOutput = EditorServerCommandRunner.RunCommand("npm", "install", skillsDir, NpmInstallExtendedTimeoutSeconds);
|
||||
InstallLog += "✓ Dependencies installed\n";
|
||||
|
||||
// Step 5: npm run build
|
||||
InstallLog += "[5/5] Building CLI (npm run build)...\n";
|
||||
string buildOutput = EditorServerCommandRunner.RunCommand("npm", "run build", skillsDir, NpmBuildTimeoutSeconds);
|
||||
InstallLog += "✓ Build completed\n";
|
||||
|
||||
// Create CLI wrapper
|
||||
CreateCLIWrapper(outputDir, skillsDir);
|
||||
|
||||
InstallLog += "\n✅ CLI installation completed successfully!\n";
|
||||
ToolkitLogger.Log("CLIInstaller", "CLI scripts installed successfully");
|
||||
|
||||
// Refresh version info
|
||||
CheckVersion();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
InstallLog += $"\n❌ ERROR: {e.Message}\n";
|
||||
|
||||
// Check for common errors and provide hints
|
||||
if (e.Message.Contains("ENOSPC"))
|
||||
{
|
||||
InstallLog += "\n💡 Hint: Disk space full. Free up space and try again.\n";
|
||||
}
|
||||
else if (e.Message.Contains("EACCES") || e.Message.Contains("permission"))
|
||||
{
|
||||
InstallLog += "\n💡 Hint: Permission denied. Check folder permissions or run as administrator.\n";
|
||||
}
|
||||
else if (e.Message.Contains("ETIMEDOUT") || e.Message.Contains("network"))
|
||||
{
|
||||
InstallLog += "\n💡 Hint: Network timeout. Check your internet connection.\n";
|
||||
InstallLog += " If behind a proxy, configure npm:\n";
|
||||
InstallLog += " npm config set proxy http://proxy.company.com:8080\n";
|
||||
}
|
||||
|
||||
ToolkitLogger.LogError("CLIInstaller", $"CLI installation failed: {e.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseLock(lockFile);
|
||||
IsInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLockStale(string lockPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string[] lines = File.ReadAllLines(lockPath);
|
||||
if (lines.Length < 2) return true;
|
||||
|
||||
// Check if process is running
|
||||
if (int.TryParse(lines[0], out int pid))
|
||||
{
|
||||
if (pid <= 0) return true;
|
||||
|
||||
int currentPID = Process.GetCurrentProcess().Id;
|
||||
if (pid == currentPID) return false;
|
||||
|
||||
try
|
||||
{
|
||||
Process process = Process.GetProcessById(pid);
|
||||
if (process.HasExited) return true;
|
||||
|
||||
string processName = process.ProcessName.ToLower();
|
||||
bool isUnityEditor = processName.Contains("unity") && !processName.Contains("unityhub");
|
||||
|
||||
if (isUnityEditor) return false;
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException) { return true; }
|
||||
catch (InvalidOperationException) { return true; }
|
||||
}
|
||||
|
||||
// Fallback to timestamp
|
||||
if (DateTime.TryParse(lines[1], out DateTime lockTimestamp))
|
||||
{
|
||||
if (lockTimestamp > DateTime.Now.AddMinutes(TimestampFutureToleranceMinutes)) return true;
|
||||
if ((DateTime.Now - lockTimestamp).TotalMinutes > LockFileStaleMinutes) return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool AcquireLock(string lockPath, out string errorMessage)
|
||||
{
|
||||
errorMessage = null;
|
||||
DateTime startTime = DateTime.Now;
|
||||
int timeoutSeconds = NpmInstallTimeoutSeconds;
|
||||
|
||||
while ((DateTime.Now - startTime).TotalSeconds < timeoutSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to open or create the lock file atomically
|
||||
using (FileStream fs = File.Open(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
// Check if file has content (existing lock)
|
||||
if (fs.Length > 0)
|
||||
{
|
||||
// Read existing lock content
|
||||
byte[] buffer = new byte[fs.Length];
|
||||
fs.Read(buffer, 0, (int)fs.Length);
|
||||
string existingContent = Encoding.UTF8.GetString(buffer);
|
||||
string[] lines = existingContent.Split('\n');
|
||||
|
||||
// Check if lock is stale
|
||||
bool isStale = false;
|
||||
if (lines.Length >= 2)
|
||||
{
|
||||
if (int.TryParse(lines[0], out int pid))
|
||||
{
|
||||
int currentPID = Process.GetCurrentProcess().Id;
|
||||
if (pid == currentPID)
|
||||
{
|
||||
// Same process, reuse lock
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process process = Process.GetProcessById(pid);
|
||||
if (process.HasExited)
|
||||
{
|
||||
isStale = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
string processName = process.ProcessName.ToLower();
|
||||
bool isUnityEditor = processName.Contains("unity") && !processName.Contains("unityhub");
|
||||
if (!isUnityEditor)
|
||||
{
|
||||
isStale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ArgumentException) { isStale = true; }
|
||||
catch (InvalidOperationException) { isStale = true; }
|
||||
}
|
||||
|
||||
// Check timestamp as fallback
|
||||
if (!isStale && DateTime.TryParse(lines[1], out DateTime lockTimestamp))
|
||||
{
|
||||
if (lockTimestamp > DateTime.Now.AddMinutes(TimestampFutureToleranceMinutes) ||
|
||||
(DateTime.Now - lockTimestamp).TotalMinutes > LockFileStaleMinutes)
|
||||
{
|
||||
isStale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
isStale = true; // Invalid format
|
||||
}
|
||||
|
||||
if (!isStale)
|
||||
{
|
||||
// Lock is valid and owned by another process
|
||||
fs.Close();
|
||||
System.Threading.Thread.Sleep(LockAcquisitionRetryIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lock is stale, overwrite it
|
||||
fs.SetLength(0);
|
||||
fs.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
// Write new lock content
|
||||
int currentPID2 = Process.GetCurrentProcess().Id;
|
||||
string lockContent = $"{currentPID2}\n{DateTime.Now:yyyy-MM-dd HH:mm:ss}";
|
||||
byte[] info = Encoding.UTF8.GetBytes(lockContent);
|
||||
fs.Write(info, 0, info.Length);
|
||||
fs.Flush();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File is locked by another process
|
||||
System.Threading.Thread.Sleep(LockAcquisitionRetryIntervalMs);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
errorMessage = $"Lock acquisition failed: {e.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage = "Another Unity instance is installing CLI scripts. Please wait and try again.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ReleaseLock(string lockPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(lockPath))
|
||||
{
|
||||
File.Delete(lockPath);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", $"Failed to release lock: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckDiskSpace(string path, long requiredMB = 500)
|
||||
{
|
||||
try
|
||||
{
|
||||
string rootPath = Path.GetPathRoot(path);
|
||||
if (string.IsNullOrEmpty(rootPath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", "Could not determine drive root path");
|
||||
return true; // Cannot check, assume OK
|
||||
}
|
||||
|
||||
DriveInfo drive = new DriveInfo(rootPath);
|
||||
|
||||
// Check if drive is ready (mounted and accessible)
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", $"Drive {rootPath} is not ready");
|
||||
return true; // Cannot check, assume OK
|
||||
}
|
||||
|
||||
long availableMB = drive.AvailableFreeSpace / (1024 * 1024);
|
||||
InstallLog += $"💾 Disk space: {availableMB}MB available on {rootPath}\n";
|
||||
|
||||
if (availableMB < requiredMB)
|
||||
{
|
||||
InstallLog += $"⚠️ Low disk space: {requiredMB}MB recommended\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", $"Invalid drive path: {e.Message}");
|
||||
return true; // Cannot check, assume OK
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", $"Could not check disk space: {e.Message}");
|
||||
return true; // Cannot check, assume OK
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckWritePermission(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string testFile = Path.Combine(directory, ".writetest");
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CLIInstaller", $"Write permission check failed: {e.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateCLIWrapper(string outputDir, string skillsDir)
|
||||
{
|
||||
string wrapperPath = Path.Combine(outputDir, "uw.js");
|
||||
string wrapperContent = @"#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Unity WebSocket CLI Wrapper
|
||||
*
|
||||
* This wrapper script forwards all arguments to the local CLI installation.
|
||||
* Auto-generated by Unity Editor Toolkit.
|
||||
*
|
||||
* Usage: node .unity-websocket/uw.js <command> [options]
|
||||
* Example: node .unity-websocket/uw.js hierarchy
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Set CLAUDE_PROJECT_DIR to project root (parent of .unity-websocket)
|
||||
process.env.CLAUDE_PROJECT_DIR = path.resolve(__dirname, '..');
|
||||
|
||||
// Get the actual CLI path
|
||||
const cliPath = path.join(__dirname, 'skills', 'scripts', 'dist', 'cli', 'cli.js');
|
||||
|
||||
// Forward to the actual CLI
|
||||
require(cliPath);
|
||||
";
|
||||
|
||||
File.WriteAllText(wrapperPath, wrapperContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50e367fb4a28f304889d8a82ec665e30
|
||||
213
skills/assets/unity-package/Editor/EditorServerCommandRunner.cs
Normal file
213
skills/assets/unity-package/Editor/EditorServerCommandRunner.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles external command execution (Node.js, npm, etc.)
|
||||
/// </summary>
|
||||
public static class EditorServerCommandRunner
|
||||
{
|
||||
private const int ProcessKillWaitTimeoutMs = 5000; // 5 seconds
|
||||
private const int DefaultCommandTimeoutSeconds = 120; // 2 minutes
|
||||
|
||||
/// <summary>
|
||||
/// Check if Node.js is installed and accessible
|
||||
/// </summary>
|
||||
public static bool CheckNodeInstallation()
|
||||
{
|
||||
return !string.IsNullOrEmpty(GetNodeVersion());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get Node.js version string (e.g., "v22.17.0")
|
||||
/// </summary>
|
||||
public static string GetNodeVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
string nodeCommand = Application.platform == RuntimePlatform.WindowsEditor ? "node.exe" : "node";
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = nodeCommand,
|
||||
Arguments = "--version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using (Process process = Process.Start(startInfo))
|
||||
{
|
||||
process.WaitForExit(ProcessKillWaitTimeoutMs);
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return process.StandardOutput.ReadToEnd().Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a command with arguments in a specific working directory
|
||||
/// </summary>
|
||||
public static string RunCommand(string command, string arguments, string workingDirectory, int timeoutSeconds = DefaultCommandTimeoutSeconds)
|
||||
{
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo();
|
||||
|
||||
// Platform-specific command execution
|
||||
// On Windows, .cmd files must be executed through cmd.exe when UseShellExecute = false
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor && command == "npm")
|
||||
{
|
||||
startInfo.FileName = "cmd.exe";
|
||||
startInfo.Arguments = $"/c npm {arguments}";
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = command;
|
||||
startInfo.Arguments = arguments;
|
||||
}
|
||||
|
||||
startInfo.WorkingDirectory = workingDirectory;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
startInfo.CreateNoWindow = true;
|
||||
startInfo.StandardOutputEncoding = Encoding.UTF8;
|
||||
startInfo.StandardErrorEncoding = Encoding.UTF8;
|
||||
|
||||
// Explicitly copy environment variables to ensure npm can find its dependencies
|
||||
// When UseShellExecute = false, environment variables are not automatically inherited
|
||||
// This fixes issues where npm.cmd cannot find node.exe or its internal modules
|
||||
foreach (System.Collections.DictionaryEntry envVar in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
try
|
||||
{
|
||||
string key = envVar.Key.ToString();
|
||||
string value = envVar.Value.ToString();
|
||||
|
||||
// Add or update environment variable (case-insensitive on Windows)
|
||||
if (startInfo.EnvironmentVariables.ContainsKey(key))
|
||||
{
|
||||
startInfo.EnvironmentVariables[key] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.EnvironmentVariables.Add(key, value);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip problematic environment variables (e.g., null values)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Process process = null;
|
||||
StringBuilder outputBuilder = new StringBuilder();
|
||||
StringBuilder errorBuilder = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
if (process == null)
|
||||
{
|
||||
throw new Exception($"Failed to start process: {command}");
|
||||
}
|
||||
|
||||
// Read output asynchronously to prevent deadlocks
|
||||
process.OutputDataReceived += (sender, e) => {
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (outputBuilder)
|
||||
{
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (sender, e) => {
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (errorBuilder)
|
||||
{
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
bool exited = process.WaitForExit(timeoutSeconds * 1000);
|
||||
|
||||
if (!exited)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandRunner", $"Process timeout after {timeoutSeconds}s, killing: {command} {arguments}");
|
||||
|
||||
try
|
||||
{
|
||||
// Kill the process tree (including child processes)
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill();
|
||||
bool killed = process.WaitForExit(ProcessKillWaitTimeoutMs);
|
||||
|
||||
if (!killed)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandRunner", "Process did not terminate after kill signal");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception killEx)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandRunner", $"Failed to kill process: {killEx.Message}");
|
||||
}
|
||||
|
||||
throw new Exception($"{command} timed out after {timeoutSeconds} seconds. Check network connection or increase timeout.");
|
||||
}
|
||||
|
||||
// Wait for async output reading to complete
|
||||
process.WaitForExit();
|
||||
|
||||
string output = outputBuilder.ToString();
|
||||
string error = errorBuilder.ToString();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new Exception($"{command} {arguments} failed (exit code {process.ExitCode}):\n{error}");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always cleanup process resources
|
||||
if (process != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill();
|
||||
process.WaitForExit(ProcessKillWaitTimeoutMs);
|
||||
}
|
||||
process.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandRunner", $"Error disposing process: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e680fb7f17ff34747a963b640f3eee23
|
||||
258
skills/assets/unity-package/Editor/EditorServerPathManager.cs
Normal file
258
skills/assets/unity-package/Editor/EditorServerPathManager.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles path management for plugin scripts and CLI installation
|
||||
/// </summary>
|
||||
public static class EditorServerPathManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the default plugin scripts path in user's home directory
|
||||
/// </summary>
|
||||
public static string GetDefaultPluginScriptsPath()
|
||||
{
|
||||
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(homeFolder, ".claude", "plugins", "marketplaces", "dev-gom-plugins",
|
||||
"plugins", "unity-editor-toolkit", "skills", "scripts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the plugin scripts path (custom or default)
|
||||
/// </summary>
|
||||
public static string FindPluginScriptsPath(string customPath = null)
|
||||
{
|
||||
// Use custom path if set
|
||||
if (!string.IsNullOrEmpty(customPath))
|
||||
{
|
||||
// Security: Validate path to prevent path traversal
|
||||
string normalized = Path.GetFullPath(customPath);
|
||||
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
string allowedPath = Path.Combine(homeFolder, ".claude", "plugins");
|
||||
|
||||
if (!normalized.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogError("PathManager", $"Plugin path outside allowed directory: {customPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Directory.Exists(normalized) &&
|
||||
File.Exists(Path.Combine(normalized, "package.json")))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Use default home folder based path
|
||||
string defaultPath = GetDefaultPluginScriptsPath();
|
||||
if (Directory.Exists(defaultPath) &&
|
||||
File.Exists(Path.Combine(defaultPath, "package.json")))
|
||||
{
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find package.json path in Unity Package
|
||||
/// </summary>
|
||||
public static string FindPackageJsonPath()
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
|
||||
// Try to find the package.json in various locations
|
||||
string[][] searchPathComponents = new string[][]
|
||||
{
|
||||
// Installed via Package Manager
|
||||
new string[] { "Packages", "com.devgom.unity-editor-toolkit", "package.json" },
|
||||
// Installed in Assets
|
||||
new string[] { "Assets", "UnityEditorToolkit", "package.json" },
|
||||
new string[] { "Assets", "Packages", "UnityEditorToolkit", "package.json" }
|
||||
};
|
||||
|
||||
foreach (string[] components in searchPathComponents)
|
||||
{
|
||||
// Build path using Path.Combine for cross-platform compatibility
|
||||
string[] fullPathComponents = new string[components.Length + 1];
|
||||
fullPathComponents[0] = projectRoot;
|
||||
Array.Copy(components, 0, fullPathComponents, 1, components.Length);
|
||||
|
||||
string fullPath = Path.Combine(fullPathComponents);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract version from package.json content
|
||||
/// </summary>
|
||||
public static string ExtractVersionFromJson(string json)
|
||||
{
|
||||
// Simple regex to extract version (avoiding full JSON parser)
|
||||
Match match = Regex.Match(json, @"""version""\s*:\s*""([^""]+)""");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get plugin version from package.json
|
||||
/// </summary>
|
||||
public static string GetPluginVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find package.json in the Unity Package
|
||||
string packagePath = FindPackageJsonPath();
|
||||
if (string.IsNullOrEmpty(packagePath) || !File.Exists(packagePath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("PathManager", "package.json not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(packagePath);
|
||||
return ExtractVersionFromJson(json);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ToolkitLogger.LogError("PathManager", $"Unity Editor Toolkit: Failed to read plugin version: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get local CLI version from installed scripts
|
||||
/// </summary>
|
||||
public static string GetLocalCLIVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
string localPackageJson = Path.Combine(projectRoot, ".unity-websocket", "skills", "scripts", "package.json");
|
||||
|
||||
if (!File.Exists(localPackageJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(localPackageJson);
|
||||
return ExtractVersionFromJson(json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get home folder CLI version (source version to be installed)
|
||||
/// </summary>
|
||||
public static string GetHomeCLIVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
string homeCLIPath = FindPluginScriptsPath();
|
||||
if (string.IsNullOrEmpty(homeCLIPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string homePackageJson = Path.Combine(homeCLIPath, "package.json");
|
||||
if (!File.Exists(homePackageJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(homePackageJson);
|
||||
return ExtractVersionFromJson(json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy directory with security validation
|
||||
/// </summary>
|
||||
public static void CopyDirectory(string sourceDir, string destDir)
|
||||
{
|
||||
// Security: Validate and normalize paths to prevent path traversal
|
||||
string normalizedSource = Path.GetFullPath(sourceDir);
|
||||
string normalizedDest = Path.GetFullPath(destDir);
|
||||
|
||||
// Validate source is in allowed plugin directory
|
||||
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
string allowedPluginPath = Path.Combine(homeFolder, ".claude", "plugins");
|
||||
|
||||
if (!normalizedSource.StartsWith(allowedPluginPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Source path outside allowed directory: {sourceDir}");
|
||||
}
|
||||
|
||||
// Validate destination is within project
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
if (!normalizedDest.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Destination path outside project: {destDir}");
|
||||
}
|
||||
|
||||
// Check for symbolic links (security risk)
|
||||
DirectoryInfo sourceInfo = new DirectoryInfo(normalizedSource);
|
||||
if ((sourceInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
|
||||
{
|
||||
throw new UnauthorizedAccessException("Symbolic links are not allowed");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(normalizedDest);
|
||||
|
||||
// Copy files with validation
|
||||
foreach (string file in Directory.GetFiles(normalizedSource))
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
|
||||
// Validate filename (prevent null byte injection)
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || fileName.Contains('\0'))
|
||||
{
|
||||
ToolkitLogger.LogWarning("PathManager", $"Skipping invalid file name: {fileName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
string destFile = Path.Combine(normalizedDest, fileName);
|
||||
|
||||
// Validate final path stays within destination
|
||||
string normalizedDestFile = Path.GetFullPath(destFile);
|
||||
if (!normalizedDestFile.StartsWith(normalizedDest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogWarning("PathManager", $"Skipping file outside destination: {fileName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Copy(file, normalizedDestFile, true);
|
||||
}
|
||||
|
||||
// Copy subdirectories recursively with validation
|
||||
foreach (string dir in Directory.GetDirectories(normalizedSource))
|
||||
{
|
||||
string dirName = Path.GetFileName(dir);
|
||||
|
||||
// Skip node_modules, dist, hidden folders, and cache
|
||||
if (dirName == "node_modules" || dirName == "dist" ||
|
||||
dirName.StartsWith(".") || dirName == "__pycache__")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string destSubDir = Path.Combine(normalizedDest, dirName);
|
||||
CopyDirectory(dir, destSubDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e62f769f30c221488d0557814e98c52
|
||||
654
skills/assets/unity-package/Editor/EditorServerWindow.cs
Normal file
654
skills/assets/unity-package/Editor/EditorServerWindow.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditorToolkit.Editor.Server;
|
||||
using UnityEditorToolkit.Editor.Attributes;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using System.IO;
|
||||
using UnityEditor.Callbacks;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor window for Unity Editor Toolkit Server (UI Toolkit version)
|
||||
/// </summary>
|
||||
public partial class EditorServerWindow : EditorWindow
|
||||
{
|
||||
private EditorWebSocketServer server => EditorWebSocketServer.Instance;
|
||||
private EditorServerCLIInstaller cliInstaller;
|
||||
|
||||
// Data binding source
|
||||
private EditorServerWindowData windowData = new EditorServerWindowData();
|
||||
|
||||
private bool wasPlaying = false;
|
||||
private float lastUpdateTime = 0f;
|
||||
private bool hasNodeJS = false;
|
||||
|
||||
private const string PREF_KEY_PLUGIN_PATH = "UnityEditorToolkit.PluginScriptsPath";
|
||||
private const float UI_UPDATE_INTERVAL_SECONDS = 0.5f;
|
||||
|
||||
// UI Elements
|
||||
private VisualElement statusIndicator;
|
||||
private Label serverStatusLabel;
|
||||
private Label serverPortLabel;
|
||||
private Label connectedClientsLabel;
|
||||
private Toggle autostartToggle;
|
||||
private EnumField logLevelDropdown;
|
||||
private Button startButton;
|
||||
private Button stopButton;
|
||||
|
||||
private VisualElement nodejsMissingSection;
|
||||
private VisualElement cliStatusSection;
|
||||
private Label packageVersionLabel;
|
||||
private Label cliVersionLabel;
|
||||
|
||||
// Status messages
|
||||
private HelpBox installProgressHelp;
|
||||
private HelpBox notInstalledHelp;
|
||||
private HelpBox updateMinorHelp;
|
||||
private HelpBox updateMajorHelp;
|
||||
private HelpBox upToDateHelp;
|
||||
|
||||
// Buttons
|
||||
private Button installButton;
|
||||
private Button updateButton;
|
||||
private Button reinstallButton;
|
||||
private Button clearLockButton;
|
||||
|
||||
// Installation
|
||||
private HelpBox installingMessage;
|
||||
private VisualElement installLogContainer;
|
||||
private TextField installLogField;
|
||||
private TextField pluginPathField;
|
||||
|
||||
[MenuItem("Tools/Unity Editor Toolkit/Server Window")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<EditorServerWindow>("Editor Server");
|
||||
window.minSize = new Vector2(400, 300);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Initialize CLI installer
|
||||
string pathOverride = EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null);
|
||||
cliInstaller = new EditorServerCLIInstaller(pathOverride);
|
||||
|
||||
// Check Node.js installation
|
||||
hasNodeJS = EditorServerCommandRunner.CheckNodeInstallation();
|
||||
|
||||
// Check CLI version
|
||||
if (hasNodeJS)
|
||||
{
|
||||
cliInstaller.CheckVersion();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnServerStartedHandler()
|
||||
{
|
||||
// Auto-reconnect database if it was previously connected
|
||||
if (EditorPrefs.GetBool("UnityEditorToolkit.Database.AutoReconnect", false))
|
||||
{
|
||||
EditorPrefs.DeleteKey("UnityEditorToolkit.Database.AutoReconnect");
|
||||
|
||||
// Reconnect using saved config
|
||||
string dbPath = EditorPrefs.GetString("UnityEditorToolkit.Database.Path", "");
|
||||
bool enableWAL = EditorPrefs.GetBool("UnityEditorToolkit.Database.EnableWAL", true);
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath))
|
||||
{
|
||||
var config = new DatabaseConfig
|
||||
{
|
||||
DatabaseFilePath = dbPath,
|
||||
EnableWAL = enableWAL
|
||||
};
|
||||
DatabaseManager.Instance.InitializeAsync(config).Forget();
|
||||
ToolkitLogger.Log("EditorServerWindow", "서버 시작 - 데이터베이스 자동 재연결");
|
||||
}
|
||||
}
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void OnServerStoppedHandler()
|
||||
{
|
||||
// Save auto-reconnect flag before disconnecting
|
||||
if (DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
EditorPrefs.SetBool("UnityEditorToolkit.Database.AutoReconnect", true);
|
||||
}
|
||||
|
||||
// Disconnect database when server stops
|
||||
DatabaseManager.Instance.Disconnect();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
// Load UXML
|
||||
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
|
||||
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uxml");
|
||||
|
||||
if (visualTree == null)
|
||||
{
|
||||
var label = new Label("Error: Could not load EditorServerWindow.uxml");
|
||||
label.style.color = Color.red;
|
||||
rootVisualElement.Add(label);
|
||||
return;
|
||||
}
|
||||
|
||||
visualTree.CloneTree(rootVisualElement);
|
||||
|
||||
// Set data binding source
|
||||
rootVisualElement.dataSource = windowData;
|
||||
|
||||
// Load USS
|
||||
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
|
||||
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uss");
|
||||
|
||||
if (styleSheet != null)
|
||||
{
|
||||
rootVisualElement.styleSheets.Add(styleSheet);
|
||||
}
|
||||
|
||||
// Query all UI elements
|
||||
QueryUIElements();
|
||||
|
||||
// Bind events
|
||||
BindEvents();
|
||||
|
||||
// Initialize Database UI
|
||||
InitializeDatabaseUI();
|
||||
|
||||
// Initial UI update
|
||||
UpdateUI();
|
||||
|
||||
// Log initialization status (한 번에 출력)
|
||||
LogInitializationStatus();
|
||||
}
|
||||
|
||||
private void QueryUIElements()
|
||||
{
|
||||
// Server section
|
||||
statusIndicator = rootVisualElement.Q<VisualElement>("status-indicator");
|
||||
serverStatusLabel = rootVisualElement.Q<Label>("server-status");
|
||||
serverPortLabel = rootVisualElement.Q<Label>("server-port");
|
||||
connectedClientsLabel = rootVisualElement.Q<Label>("connected-clients");
|
||||
autostartToggle = rootVisualElement.Q<Toggle>("autostart-toggle");
|
||||
logLevelDropdown = rootVisualElement.Q<EnumField>("log-level-dropdown");
|
||||
startButton = rootVisualElement.Q<Button>("start-button");
|
||||
stopButton = rootVisualElement.Q<Button>("stop-button");
|
||||
|
||||
// CLI section
|
||||
nodejsMissingSection = rootVisualElement.Q<VisualElement>("nodejs-missing");
|
||||
cliStatusSection = rootVisualElement.Q<VisualElement>("cli-status");
|
||||
packageVersionLabel = rootVisualElement.Q<Label>("package-version");
|
||||
cliVersionLabel = rootVisualElement.Q<Label>("cli-version");
|
||||
|
||||
// Status messages
|
||||
installProgressHelp = rootVisualElement.Q<HelpBox>("install-progress");
|
||||
notInstalledHelp = rootVisualElement.Q<HelpBox>("not-installed");
|
||||
updateMinorHelp = rootVisualElement.Q<HelpBox>("update-minor");
|
||||
updateMajorHelp = rootVisualElement.Q<HelpBox>("update-major");
|
||||
upToDateHelp = rootVisualElement.Q<HelpBox>("up-to-date");
|
||||
|
||||
// Buttons
|
||||
installButton = rootVisualElement.Q<Button>("install-button");
|
||||
updateButton = rootVisualElement.Q<Button>("update-button");
|
||||
reinstallButton = rootVisualElement.Q<Button>("reinstall-button");
|
||||
clearLockButton = rootVisualElement.Q<Button>("clear-lock-button");
|
||||
|
||||
// Installation
|
||||
installingMessage = rootVisualElement.Q<HelpBox>("installing-message");
|
||||
installLogContainer = rootVisualElement.Q<VisualElement>("install-log-container");
|
||||
installLogField = rootVisualElement.Q<TextField>("install-log");
|
||||
pluginPathField = rootVisualElement.Q<TextField>("plugin-path");
|
||||
|
||||
// Database section
|
||||
QueryDatabaseUIElements();
|
||||
}
|
||||
|
||||
private void BindEvents()
|
||||
{
|
||||
// Server controls
|
||||
autostartToggle?.RegisterValueChangedCallback(evt => {
|
||||
server.AutoStart = evt.newValue;
|
||||
});
|
||||
|
||||
// Initialize and bind log level dropdown
|
||||
if (logLevelDropdown != null)
|
||||
{
|
||||
logLevelDropdown.Init(server.CurrentLogLevel);
|
||||
logLevelDropdown.RegisterValueChangedCallback(evt => {
|
||||
server.CurrentLogLevel = (ToolkitLogger.LogLevel)evt.newValue;
|
||||
});
|
||||
}
|
||||
|
||||
startButton?.RegisterCallback<ClickEvent>(evt => server.StartServer());
|
||||
stopButton?.RegisterCallback<ClickEvent>(evt => server.StopServer());
|
||||
|
||||
// Server state change events
|
||||
server.OnServerStarted += OnServerStartedHandler;
|
||||
server.OnServerStopped += OnServerStoppedHandler;
|
||||
|
||||
// Node.js section
|
||||
var nodejsDownloadBtn = rootVisualElement.Q<Button>("nodejs-download-button");
|
||||
nodejsDownloadBtn?.RegisterCallback<ClickEvent>(evt =>
|
||||
Application.OpenURL("https://nodejs.org/"));
|
||||
|
||||
var recheckNodejsBtn = rootVisualElement.Q<Button>("recheck-nodejs-button");
|
||||
recheckNodejsBtn?.RegisterCallback<ClickEvent>(evt => {
|
||||
hasNodeJS = EditorServerCommandRunner.CheckNodeInstallation();
|
||||
if (hasNodeJS) cliInstaller.CheckVersion();
|
||||
UpdateUI();
|
||||
});
|
||||
|
||||
// CLI buttons
|
||||
installButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
|
||||
updateButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
|
||||
reinstallButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
|
||||
clearLockButton?.RegisterCallback<ClickEvent>(evt => {
|
||||
cliInstaller.ClearInstallationLock();
|
||||
UpdateUI();
|
||||
});
|
||||
|
||||
// Path management
|
||||
var openPathBtn = rootVisualElement.Q<Button>("open-path-button");
|
||||
openPathBtn?.RegisterCallback<ClickEvent>(evt => {
|
||||
string path = EditorServerPathManager.FindPluginScriptsPath(
|
||||
EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null));
|
||||
if (!string.IsNullOrEmpty(path) && System.IO.Directory.Exists(path))
|
||||
{
|
||||
EditorUtility.RevealInFinder(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error", "Plugin scripts path not found.", "OK");
|
||||
}
|
||||
});
|
||||
|
||||
var browsePathBtn = rootVisualElement.Q<Button>("browse-path-button");
|
||||
browsePathBtn?.RegisterCallback<ClickEvent>(evt => {
|
||||
string currentPath = EditorServerPathManager.FindPluginScriptsPath(
|
||||
EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null))
|
||||
?? EditorServerPathManager.GetDefaultPluginScriptsPath();
|
||||
|
||||
string selectedPath = EditorUtility.OpenFolderPanel(
|
||||
"Select Plugin Scripts Path",
|
||||
System.IO.Path.GetDirectoryName(currentPath),
|
||||
"");
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedPath))
|
||||
{
|
||||
// Validate the path has package.json
|
||||
if (System.IO.File.Exists(System.IO.Path.Combine(selectedPath, "package.json")))
|
||||
{
|
||||
EditorPrefs.SetString(PREF_KEY_PLUGIN_PATH, selectedPath);
|
||||
cliInstaller = new EditorServerCLIInstaller(selectedPath);
|
||||
cliInstaller.CheckVersion();
|
||||
UpdateUI();
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Invalid Path",
|
||||
"Selected folder does not contain package.json.\nPlease select the 'scripts' folder containing CLI scripts.",
|
||||
"OK");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var resetPathBtn = rootVisualElement.Q<Button>("reset-path-button");
|
||||
resetPathBtn?.RegisterCallback<ClickEvent>(evt => {
|
||||
EditorPrefs.DeleteKey(PREF_KEY_PLUGIN_PATH);
|
||||
cliInstaller = new EditorServerCLIInstaller(null);
|
||||
cliInstaller.CheckVersion();
|
||||
UpdateUI();
|
||||
});
|
||||
|
||||
// Documentation
|
||||
var openDocsBtn = rootVisualElement.Q<Button>("open-docs-button");
|
||||
openDocsBtn?.RegisterCallback<ClickEvent>(evt => {
|
||||
Application.OpenURL("https://github.com/Dev-GOM/claude-code-marketplace/tree/main/plugins/unity-editor-toolkit");
|
||||
});
|
||||
|
||||
// Database section
|
||||
BindDatabaseEvents();
|
||||
}
|
||||
|
||||
private void InstallCLI()
|
||||
{
|
||||
cliInstaller.InstallOrUpdate();
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
private void UpdateUI()
|
||||
{
|
||||
if (rootVisualElement == null || serverStatusLabel == null) return;
|
||||
|
||||
// Update server status
|
||||
UpdateServerStatus();
|
||||
|
||||
// Update CLI status
|
||||
UpdateCLIStatus();
|
||||
|
||||
// Update database status
|
||||
UpdateDatabaseUI();
|
||||
}
|
||||
|
||||
private void UpdateServerStatus()
|
||||
{
|
||||
// Update data properties (UI auto-updates via data binding)
|
||||
windowData.ServerIsRunning = server.IsRunning;
|
||||
windowData.ServerPort = server.Port;
|
||||
windowData.ConnectedClients = server.ConnectedClients;
|
||||
windowData.AutoStart = server.AutoStart;
|
||||
|
||||
// Update status indicator classes (CSS classes cannot be bound)
|
||||
if (statusIndicator != null)
|
||||
{
|
||||
statusIndicator.RemoveFromClassList("status-stopped");
|
||||
statusIndicator.RemoveFromClassList("status-running");
|
||||
statusIndicator.AddToClassList(server.IsRunning ? "status-running" : "status-stopped");
|
||||
}
|
||||
|
||||
// Update button states (not bound to data)
|
||||
autostartToggle.SetEnabled(!server.IsRunning);
|
||||
startButton.SetEnabled(!server.IsRunning);
|
||||
stopButton.SetEnabled(server.IsRunning);
|
||||
}
|
||||
|
||||
private void UpdateCLIStatus()
|
||||
{
|
||||
bool installInProgress = cliInstaller.IsInstallationInProgress();
|
||||
|
||||
// Show/hide Node.js sections
|
||||
if (!hasNodeJS)
|
||||
{
|
||||
nodejsMissingSection?.RemoveFromClassList("hidden");
|
||||
cliStatusSection?.AddToClassList("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
nodejsMissingSection?.AddToClassList("hidden");
|
||||
cliStatusSection?.RemoveFromClassList("hidden");
|
||||
|
||||
// Update version data (UI auto-updates via data binding)
|
||||
windowData.PackageVersion = cliInstaller.PluginVersion ?? "Unknown";
|
||||
windowData.CLIVersion = cliInstaller.LocalCLIVersion != null
|
||||
? $"✅ {cliInstaller.LocalCLIVersion}"
|
||||
: "❌ Not Installed";
|
||||
|
||||
// Hide all status messages first
|
||||
installProgressHelp?.AddToClassList("hidden");
|
||||
notInstalledHelp?.AddToClassList("hidden");
|
||||
updateMinorHelp?.AddToClassList("hidden");
|
||||
updateMajorHelp?.AddToClassList("hidden");
|
||||
upToDateHelp?.AddToClassList("hidden");
|
||||
|
||||
// Show appropriate status message
|
||||
if (installInProgress)
|
||||
{
|
||||
installProgressHelp?.RemoveFromClassList("hidden");
|
||||
}
|
||||
else if (cliInstaller.LocalCLIVersion == null)
|
||||
{
|
||||
notInstalledHelp?.RemoveFromClassList("hidden");
|
||||
}
|
||||
else if (cliInstaller.UpdateAvailable)
|
||||
{
|
||||
bool isMinorUpdate = EditorServerCLIInstaller.IsMinorVersionDifference(
|
||||
cliInstaller.LocalCLIVersion, cliInstaller.HomeCLIVersion);
|
||||
|
||||
if (isMinorUpdate)
|
||||
{
|
||||
if (updateMinorHelp != null)
|
||||
{
|
||||
updateMinorHelp.text = $"CLI update available: {cliInstaller.LocalCLIVersion} → {cliInstaller.HomeCLIVersion}\n(Minor update, current version still works)";
|
||||
updateMinorHelp.RemoveFromClassList("hidden");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (updateMajorHelp != null)
|
||||
{
|
||||
updateMajorHelp.text = $"CLI update available: {cliInstaller.LocalCLIVersion} → {cliInstaller.HomeCLIVersion}\n(Recommended to update)";
|
||||
updateMajorHelp.RemoveFromClassList("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
upToDateHelp?.RemoveFromClassList("hidden");
|
||||
}
|
||||
|
||||
// Button visibility
|
||||
UpdateButtonVisibility(installInProgress);
|
||||
|
||||
// Installation progress
|
||||
if (cliInstaller.IsInstalling)
|
||||
{
|
||||
installingMessage?.RemoveFromClassList("hidden");
|
||||
}
|
||||
else
|
||||
{
|
||||
installingMessage?.AddToClassList("hidden");
|
||||
}
|
||||
|
||||
// Installation log (only on error)
|
||||
UpdateInstallLog();
|
||||
|
||||
// Plugin path
|
||||
UpdatePluginPath();
|
||||
}
|
||||
|
||||
private void UpdateButtonVisibility(bool installInProgress)
|
||||
{
|
||||
// Hide all buttons first
|
||||
installButton?.AddToClassList("hidden");
|
||||
updateButton?.AddToClassList("hidden");
|
||||
reinstallButton?.AddToClassList("hidden");
|
||||
clearLockButton?.AddToClassList("hidden");
|
||||
|
||||
bool canInstall = !cliInstaller.IsInstalling && !installInProgress;
|
||||
|
||||
// Show appropriate button
|
||||
if (cliInstaller.LocalCLIVersion == null)
|
||||
{
|
||||
installButton?.RemoveFromClassList("hidden");
|
||||
installButton?.SetEnabled(canInstall);
|
||||
}
|
||||
else if (cliInstaller.UpdateAvailable)
|
||||
{
|
||||
updateButton?.RemoveFromClassList("hidden");
|
||||
updateButton?.SetEnabled(canInstall);
|
||||
}
|
||||
else
|
||||
{
|
||||
reinstallButton?.RemoveFromClassList("hidden");
|
||||
reinstallButton?.SetEnabled(canInstall);
|
||||
}
|
||||
|
||||
if (installInProgress)
|
||||
{
|
||||
clearLockButton?.RemoveFromClassList("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInstallLog()
|
||||
{
|
||||
if (string.IsNullOrEmpty(cliInstaller.InstallLog))
|
||||
{
|
||||
installLogContainer?.AddToClassList("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if log contains error
|
||||
string logLower = cliInstaller.InstallLog.ToLower();
|
||||
bool hasError = logLower.Contains("error") ||
|
||||
logLower.Contains("failed") ||
|
||||
logLower.Contains("exception") ||
|
||||
logLower.Contains("cannot find") ||
|
||||
logLower.Contains("command not found");
|
||||
|
||||
if (hasError)
|
||||
{
|
||||
installLogContainer?.RemoveFromClassList("hidden");
|
||||
if (installLogField != null)
|
||||
installLogField.value = cliInstaller.InstallLog;
|
||||
}
|
||||
else
|
||||
{
|
||||
installLogContainer?.AddToClassList("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePluginPath()
|
||||
{
|
||||
if (pluginPathField == null) return;
|
||||
|
||||
string pathOverride = EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null);
|
||||
string displayPath = EditorServerPathManager.FindPluginScriptsPath(pathOverride)
|
||||
?? EditorServerPathManager.GetDefaultPluginScriptsPath();
|
||||
pluginPathField.value = displayPath;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
bool needsUpdate = false;
|
||||
|
||||
// Play Mode state change detection
|
||||
if (Application.isPlaying != wasPlaying)
|
||||
{
|
||||
wasPlaying = Application.isPlaying;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Periodic update
|
||||
float currentTime = (float)EditorApplication.timeSinceStartup;
|
||||
if (currentTime - lastUpdateTime > UI_UPDATE_INTERVAL_SECONDS)
|
||||
{
|
||||
lastUpdateTime = currentTime;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Update UI when needed
|
||||
if (needsUpdate)
|
||||
{
|
||||
UpdateUI();
|
||||
}
|
||||
}
|
||||
|
||||
private void LogInitializationStatus()
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
sb.AppendLine("✓ Unity Editor Toolkit - Initialized");
|
||||
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
|
||||
// Node.js 상태
|
||||
if (hasNodeJS)
|
||||
{
|
||||
string nodeVersion = EditorServerCommandRunner.GetNodeVersion();
|
||||
sb.AppendLine($" Node.js: {nodeVersion}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" Node.js: Not installed");
|
||||
}
|
||||
|
||||
// CLI 상태
|
||||
if (cliInstaller != null)
|
||||
{
|
||||
string cliVersion = cliInstaller.LocalCLIVersion;
|
||||
sb.AppendLine($" CLI Version: {(string.IsNullOrEmpty(cliVersion) ? "Not installed" : cliVersion)}");
|
||||
}
|
||||
|
||||
// Database 상태
|
||||
if (currentDbConfig != null)
|
||||
{
|
||||
sb.AppendLine($" Database: {(currentDbConfig.EnableDatabase ? "Enabled" : "Disabled")}");
|
||||
if (currentDbConfig.EnableDatabase)
|
||||
{
|
||||
sb.AppendLine($" - WAL Mode: {(currentDbConfig.EnableWAL ? "Enabled" : "Disabled")}");
|
||||
sb.AppendLine($" - File Path: {currentDbConfig.DatabaseFilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Server 상태
|
||||
sb.AppendLine($" WebSocket Server: {(server.IsRunning ? $"Running (Port {server.Port})" : "Stopped")}");
|
||||
|
||||
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
|
||||
ToolkitLogger.Log("EditorServerWindow", sb.ToString());
|
||||
}
|
||||
|
||||
#region Executable Methods (CLI)
|
||||
|
||||
/// <summary>
|
||||
/// Reinstall Unity Editor Toolkit CLI (executable via CLI)
|
||||
/// </summary>
|
||||
[ExecutableMethod("reinstall-cli", "Reinstall Unity Editor Toolkit CLI")]
|
||||
public static void ReinstallCLI()
|
||||
{
|
||||
var window = GetWindow<EditorServerWindow>("Unity Editor Toolkit");
|
||||
if (window == null)
|
||||
{
|
||||
ToolkitLogger.LogError("EditorServerWindow", "Failed to get window instance for CLI reinstall");
|
||||
throw new System.Exception("Failed to get Unity Editor Toolkit window instance");
|
||||
}
|
||||
|
||||
if (window.cliInstaller == null)
|
||||
{
|
||||
ToolkitLogger.LogError("EditorServerWindow", "CLI installer is not initialized");
|
||||
throw new System.Exception("CLI installer is not initialized");
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("EditorServerWindow", "Reinstalling CLI via execute command...");
|
||||
window.cliInstaller.InstallOrUpdate();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// Unsubscribe from server events
|
||||
if (server != null)
|
||||
{
|
||||
server.OnServerStarted -= OnServerStartedHandler;
|
||||
server.OnServerStopped -= OnServerStoppedHandler;
|
||||
}
|
||||
|
||||
// UI Toolkit automatically cleans up event handlers when the window closes
|
||||
// Clear any references to prevent potential memory leaks
|
||||
statusIndicator = null;
|
||||
serverStatusLabel = null;
|
||||
serverPortLabel = null;
|
||||
connectedClientsLabel = null;
|
||||
autostartToggle = null;
|
||||
startButton = null;
|
||||
stopButton = null;
|
||||
nodejsMissingSection = null;
|
||||
cliStatusSection = null;
|
||||
packageVersionLabel = null;
|
||||
cliVersionLabel = null;
|
||||
installProgressHelp = null;
|
||||
notInstalledHelp = null;
|
||||
updateMinorHelp = null;
|
||||
updateMajorHelp = null;
|
||||
upToDateHelp = null;
|
||||
installButton = null;
|
||||
updateButton = null;
|
||||
reinstallButton = null;
|
||||
clearLockButton = null;
|
||||
installingMessage = null;
|
||||
installLogContainer = null;
|
||||
installLogField = null;
|
||||
pluginPathField = null;
|
||||
|
||||
// Database section cleanup
|
||||
CleanupDatabaseUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 570a7561ca0226e44a98c146ae5c1b02
|
||||
162
skills/assets/unity-package/Editor/EditorServerWindow.uss
Normal file
162
skills/assets/unity-package/Editor/EditorServerWindow.uss
Normal file
@@ -0,0 +1,162 @@
|
||||
/* Root Container */
|
||||
.root-container {
|
||||
padding: 10px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Section Styling */
|
||||
.section {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
-unity-font-style: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 12px;
|
||||
-unity-font-style: bold;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Status Row */
|
||||
.status-row {
|
||||
flex-direction: row;
|
||||
margin-bottom: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-key {
|
||||
min-width: 150px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.label-value {
|
||||
flex-grow: 1;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
/* Status Indicator */
|
||||
.status-indicator-container {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background-color: rgb(150, 150, 150);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background-color: rgb(80, 200, 80);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgb(220, 80, 80);
|
||||
}
|
||||
|
||||
/* Toggle Field */
|
||||
.toggle-field {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Button Row */
|
||||
.button-row {
|
||||
flex-direction: row;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.button-row > Button {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 0;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.action-button {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Path Field */
|
||||
.path-field {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Log Field */
|
||||
.log-field {
|
||||
height: 100px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Info Scroll */
|
||||
.info-scroll {
|
||||
max-height: 300px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
/* Hidden Class */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Help Box Spacing */
|
||||
.unity-help-box {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Toolbar Extension Styles */
|
||||
.unity-toolbar-element {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.unity-toolbar-label {
|
||||
font-size: 11px;
|
||||
-unity-font-style: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.unity-toolbar-button {
|
||||
font-size: 11px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.server-running {
|
||||
color: rgb(76, 255, 76);
|
||||
}
|
||||
|
||||
.server-stopped {
|
||||
color: rgb(255, 76, 76);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be9c07b31346a74489940e8850d3ac83
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||
disableValidation: 0
|
||||
274
skills/assets/unity-package/Editor/EditorServerWindow.uxml
Normal file
274
skills/assets/unity-package/Editor/EditorServerWindow.uxml
Normal file
@@ -0,0 +1,274 @@
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
|
||||
<ui:VisualElement name="root" class="root-container">
|
||||
<ui:TabView>
|
||||
<!-- Tab 1: Server & CLI -->
|
||||
<ui:Tab label="🖥️ Server & CLI" name="server-cli-tab">
|
||||
<ui:ScrollView mode="Vertical" class="scroll-container">
|
||||
<ui:VisualElement style="padding: 10px;">
|
||||
<!-- Server Status Section -->
|
||||
<ui:VisualElement name="server-section" class="section">
|
||||
<ui:Label text="🖥️ Server Status" class="section-title" />
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="📡 Status:" class="label-key" />
|
||||
<ui:VisualElement class="status-indicator-container">
|
||||
<ui:VisualElement name="status-indicator" class="status-indicator status-stopped" />
|
||||
<ui:Label name="server-status" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="ServerStatusText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="🔌 Port:" class="label-key" />
|
||||
<ui:Label name="server-port" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="ServerPortText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="👥 Connected Clients:" class="label-key" />
|
||||
<ui:Label name="connected-clients" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="ConnectedClientsText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:Toggle name="autostart-toggle" label="🚀 Auto-start server on Unity launch" class="toggle-field">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="value" data-source-path="AutoStart" binding-mode="TwoWay" />
|
||||
</Bindings>
|
||||
</ui:Toggle>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="📝 Log Level:" class="label-key" />
|
||||
<uie:EnumField name="log-level-dropdown" class="enum-field" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="start-button" text="▶️ Start Server" class="action-button" />
|
||||
<ui:Button name="stop-button" text="⏹️ Stop Server" class="action-button" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:HelpBox name="server-info" text="Server runs automatically in the background. Works in both Edit Mode and Play Mode." message-type="Info" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- CLI Status Section -->
|
||||
<ui:VisualElement name="cli-section" class="section">
|
||||
<ui:Label text="💻 CLI Scripts" class="section-title" />
|
||||
|
||||
<!-- Node.js not installed view -->
|
||||
<ui:VisualElement name="nodejs-missing" class="hidden">
|
||||
<ui:HelpBox text="Node.js is not installed or not in PATH. Please install Node.js from https://nodejs.org/ Recommended version: 18.x or higher After installation, restart Unity Editor." message-type="Error" />
|
||||
<ui:Button name="nodejs-download-button" text="🌐 Open Node.js Download Page" class="primary-button" />
|
||||
<ui:Button name="recheck-nodejs-button" text="🔄 Recheck Node.js Installation" class="secondary-button" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Node.js installed view -->
|
||||
<ui:VisualElement name="cli-status" class="hidden">
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="📦 Package Version:" class="label-key" />
|
||||
<ui:Label name="package-version" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="PackageVersion" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="💿 Local CLI Version:" class="label-key" />
|
||||
<ui:Label name="cli-version" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="CLIVersion" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Status messages -->
|
||||
<ui:HelpBox name="install-progress" text="CLI installation is in progress... If this persists for more than 10 minutes, click 'Clear Lock' below." message-type="Info" class="hidden" />
|
||||
<ui:HelpBox name="not-installed" text="CLI scripts not installed. Click 'Install CLI Scripts' to set up." message-type="Warning" class="hidden" />
|
||||
<ui:HelpBox name="update-minor" text="CLI update available" message-type="Info" class="hidden" />
|
||||
<ui:HelpBox name="update-major" text="CLI update available (Recommended to update)" message-type="Warning" class="hidden" />
|
||||
<ui:HelpBox name="up-to-date" text="CLI scripts up-to-date ✓" message-type="Info" class="hidden" />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<ui:Button name="install-button" text="📥 Install CLI Scripts" class="primary-button hidden" />
|
||||
<ui:Button name="update-button" text="⚠️ Update CLI Scripts" class="primary-button hidden" />
|
||||
<ui:Button name="reinstall-button" text="🔄 Reinstall CLI Scripts" class="secondary-button hidden" />
|
||||
<ui:Button name="clear-lock-button" text="🔓 Clear Lock (if installation stuck)" class="secondary-button hidden" />
|
||||
|
||||
<!-- Installation progress -->
|
||||
<ui:HelpBox name="installing-message" text="Installing CLI scripts... Please wait, this may take a few minutes." message-type="Info" class="hidden" />
|
||||
|
||||
<!-- Installation error log -->
|
||||
<ui:VisualElement name="install-log-container" class="hidden">
|
||||
<ui:Label text="📋 Last Installation Log (Error):" class="section-subtitle" />
|
||||
<ui:TextField name="install-log" multiline="true" readonly="true" class="log-field" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Plugin Scripts Path Configuration -->
|
||||
<ui:Label text="📁 Plugin Scripts Path" class="section-subtitle" />
|
||||
<ui:TextField name="plugin-path" readonly="true" class="path-field" />
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="open-path-button" text="📂 Open Folder" class="secondary-button" />
|
||||
<ui:Button name="browse-path-button" text="🔍 Browse..." class="secondary-button" />
|
||||
<ui:Button name="reset-path-button" text="↩️ Reset to Default" class="secondary-button" />
|
||||
</ui:VisualElement>
|
||||
<ui:HelpBox text="This is the source path where CLI scripts are loaded from. Default: Claude plugin folder (~/.claude/plugins/.../unity-editor-toolkit)" message-type="None" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
</ui:ScrollView>
|
||||
</ui:Tab>
|
||||
|
||||
<!-- Tab 2: Database -->
|
||||
<ui:Tab label="💾 Database" name="database-tab">
|
||||
<ui:ScrollView mode="Vertical" class="scroll-container">
|
||||
<ui:VisualElement style="padding: 10px;">
|
||||
<!-- Database Section -->
|
||||
<ui:VisualElement name="database-section" class="section">
|
||||
<ui:Label text="💾 Database (SQLite)" class="section-title" />
|
||||
|
||||
<!-- Setup Wizard Section -->
|
||||
<ui:VisualElement name="db-setup-section">
|
||||
<ui:Label text="⚙️ Setup Status" class="section-subtitle" />
|
||||
|
||||
<!-- Setup Status Checks -->
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="📚 SQLite:" class="label-key" />
|
||||
<ui:Label name="db-setup-sqlite-status" text="✅ Included (unity-sqlite-net)" class="label-value" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="⚡ UniTask:" class="label-key" />
|
||||
<ui:Label name="db-setup-unitask-status" text="✅ Auto-installed (package.json)" class="label-value" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Setup/Reinstall Button -->
|
||||
<ui:Button name="db-setup-button" text="🔄 Reinstall Database" class="primary-button" />
|
||||
|
||||
<!-- Setup Progress -->
|
||||
<ui:VisualElement name="db-setup-progress-container" class="hidden">
|
||||
<ui:Label name="db-setup-progress-label" text="재설치 진행 중..." class="section-subtitle" />
|
||||
<ui:ProgressBar name="db-setup-progress-bar" value="0" />
|
||||
<ui:Label name="db-setup-step-label" text="" class="label-value" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Setup Messages -->
|
||||
<ui:HelpBox name="db-setup-info-help" text="Reinstall Database 버튼을 클릭하면: 1. 데이터베이스 파일 재생성 (Application.persistentDataPath) 2. 스키마 마이그레이션 재실행 ⚠️ 기존 데이터가 삭제됩니다. 백업을 권장합니다." message-type="Warning" />
|
||||
<ui:HelpBox name="db-setup-success-help" text="" message-type="Info" class="hidden" />
|
||||
<ui:HelpBox name="db-setup-error-help" text="" message-type="Error" class="hidden" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Enable Database Toggle -->
|
||||
<ui:Toggle name="db-enable-toggle" label="✅ Enable Database Features" class="toggle-field" />
|
||||
|
||||
<!-- Database Configuration -->
|
||||
<ui:VisualElement name="db-config-section" class="hidden">
|
||||
<ui:Label text="📄 Database File Settings" class="section-subtitle" />
|
||||
<ui:TextField name="db-filepath-field" readonly="true" class="path-field" tooltip="SQLite 데이터베이스 파일 경로 기본 위치: Application.persistentDataPath 단일 파일로 모든 데이터 저장" />
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-browse-button" text="🔍 Browse..." class="secondary-button" tooltip="다른 위치에 DB 파일 저장" />
|
||||
<ui:Button name="db-open-folder-button" text="📂 Open Folder" class="secondary-button" tooltip="DB 파일이 있는 폴더 열기" />
|
||||
<ui:Button name="db-reset-path-button" text="↩️ Reset to Default" class="secondary-button" tooltip="기본 경로로 재설정" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:Toggle name="db-wal-toggle" label="⚡ Enable WAL Mode" class="toggle-field" tooltip="Write-Ahead Logging 모드 • 성능 향상 (최대 10배) • 동시 읽기 가능 • 쓰기 중에도 읽기 가능 권장: 활성화 (기본값)" />
|
||||
|
||||
<!-- Status and Controls Section -->
|
||||
<ui:VisualElement name="db-status-controls-section">
|
||||
<!-- Connection Status -->
|
||||
<ui:Label text="📊 Status" class="section-subtitle" />
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="🔌 Connection:" class="label-key" tooltip="SQLite 데이터베이스 연결 상태 Connect 버튼으로 연결" />
|
||||
<ui:VisualElement class="status-indicator-container">
|
||||
<ui:VisualElement name="db-status-indicator" class="status-indicator status-stopped" />
|
||||
<ui:Label name="db-status-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbStatusText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="💾 Database File:" class="label-key" tooltip="데이터베이스 파일 생성 여부 Reinstall Database 버튼으로 생성" />
|
||||
<ui:Label name="db-file-exists-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbFileExistsText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="🔄 Sync Status:" class="label-key" tooltip="실시간 GameObject/Component 동기화 상태 (Phase 2에서 구현 예정)" />
|
||||
<ui:Label name="db-sync-status-label" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbSyncStatusText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Connection Controls -->
|
||||
<ui:Label text="🎮 Connection Controls" class="section-subtitle" style="margin-top: 15px;" />
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-test-button" text="🔍 Test Connection" class="action-button" tooltip="데이터베이스 연결 테스트 SQLite 버전 정보 확인" />
|
||||
<ui:Button name="db-connect-button" text="🔌 Connect" class="action-button" tooltip="데이터베이스에 연결 Command History 활성화" />
|
||||
<ui:Button name="db-disconnect-button" text="⏸️ Disconnect" class="action-button hidden" tooltip="데이터베이스 연결 해제" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-migrate-button" text="⚙️ Run Migrations" class="primary-button" tooltip="스키마 마이그레이션 실행 테이블/인덱스/트리거 생성" />
|
||||
<ui:Button name="db-sync-toggle-button" text="🔄 Start Sync" class="secondary-button" tooltip="GameObject/Component 실시간 동기화 (Phase 2에서 구현 예정)" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Command History (Undo/Redo) -->
|
||||
<ui:Label text="📜 Command History" class="section-subtitle" style="margin-top: 15px;" />
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="⟲ Undo Stack:" class="label-key" tooltip="실행 취소 가능한 명령 수 데이터베이스에 영구 저장" />
|
||||
<ui:Label name="db-undo-count" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbUndoCountText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="status-row">
|
||||
<ui:Label text="⟳ Redo Stack:" class="label-key" tooltip="다시 실행 가능한 명령 수 Undo 후 사용 가능" />
|
||||
<ui:Label name="db-redo-count" class="label-value">
|
||||
<Bindings>
|
||||
<ui:DataBinding property="text" data-source-path="DbRedoCountText" binding-mode="ToTarget" />
|
||||
</Bindings>
|
||||
</ui:Label>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="db-undo-button" text="⟲ Undo" class="action-button" tooltip="마지막 명령 실행 취소 GameObject/Component 변경 복원" />
|
||||
<ui:Button name="db-redo-button" text="⟳ Redo" class="action-button" tooltip="취소한 명령 다시 실행" />
|
||||
<ui:Button name="db-clear-history-button" text="🗑️ Clear History" class="action-button" tooltip="모든 명령 히스토리 삭제 Undo/Redo 불가능" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<ui:HelpBox name="db-error-help" text="" message-type="Error" class="hidden" />
|
||||
<ui:HelpBox name="db-success-help" text="" message-type="Info" class="hidden" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Database Disabled Message -->
|
||||
<ui:VisualElement name="db-disabled-section" class="hidden">
|
||||
<ui:HelpBox text="Database features are disabled. Enable to use: • GameObject/Component persistence • Command history (Undo/Redo across sessions) • Scene snapshots • Complex SQL queries • Project analytics SQLite - 설치 불필요! • unity-sqlite-net (included in package) • UniTask (auto-installed) • Unity 6.0+ 권장 원클릭 Setup으로 바로 사용 가능합니다!" message-type="None" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
</ui:ScrollView>
|
||||
</ui:Tab>
|
||||
</ui:TabView>
|
||||
</ui:VisualElement>
|
||||
</ui:UXML>
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03acb6ac62524c941b8618ae7a99076c
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||
220
skills/assets/unity-package/Editor/EditorServerWindowData.cs
Normal file
220
skills/assets/unity-package/Editor/EditorServerWindowData.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Unity.Properties;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// EditorServerWindow의 데이터 바인딩을 위한 데이터 소스 클래스
|
||||
/// INotifyBindablePropertyChanged를 구현하여 UI 자동 업데이트
|
||||
/// </summary>
|
||||
public class EditorServerWindowData : INotifyBindablePropertyChanged
|
||||
{
|
||||
#region INotifyBindablePropertyChanged
|
||||
public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;
|
||||
|
||||
private void Notify([CallerMemberName] string propertyName = "")
|
||||
{
|
||||
propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Server Status Data
|
||||
private bool serverIsRunning = false;
|
||||
private int serverPort = 9500;
|
||||
private int connectedClients = 0;
|
||||
private bool autoStart = false;
|
||||
|
||||
[CreateProperty]
|
||||
public bool ServerIsRunning
|
||||
{
|
||||
get => serverIsRunning;
|
||||
set
|
||||
{
|
||||
if (serverIsRunning == value) return;
|
||||
serverIsRunning = value;
|
||||
Notify();
|
||||
Notify(nameof(ServerStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string ServerStatusText => ServerIsRunning ? "▶️ Running ✓" : "⏹️ Stopped";
|
||||
|
||||
[CreateProperty]
|
||||
public int ServerPort
|
||||
{
|
||||
get => serverPort;
|
||||
set
|
||||
{
|
||||
if (serverPort == value) return;
|
||||
serverPort = value;
|
||||
Notify();
|
||||
Notify(nameof(ServerPortText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string ServerPortText => ServerPort.ToString();
|
||||
|
||||
[CreateProperty]
|
||||
public int ConnectedClients
|
||||
{
|
||||
get => connectedClients;
|
||||
set
|
||||
{
|
||||
if (connectedClients == value) return;
|
||||
connectedClients = value;
|
||||
Notify();
|
||||
Notify(nameof(ConnectedClientsText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string ConnectedClientsText => ConnectedClients.ToString();
|
||||
|
||||
[CreateProperty]
|
||||
public bool AutoStart
|
||||
{
|
||||
get => autoStart;
|
||||
set
|
||||
{
|
||||
if (autoStart == value) return;
|
||||
autoStart = value;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CLI Status Data
|
||||
private bool hasNodeJS = false;
|
||||
private string packageVersion = "Unknown";
|
||||
private string cliVersion = "❌ Not Installed";
|
||||
|
||||
[CreateProperty]
|
||||
public bool HasNodeJS
|
||||
{
|
||||
get => hasNodeJS;
|
||||
set
|
||||
{
|
||||
if (hasNodeJS == value) return;
|
||||
hasNodeJS = value;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string PackageVersion
|
||||
{
|
||||
get => packageVersion;
|
||||
set
|
||||
{
|
||||
if (packageVersion == value) return;
|
||||
packageVersion = value;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string CLIVersion
|
||||
{
|
||||
get => cliVersion;
|
||||
set
|
||||
{
|
||||
if (cliVersion == value) return;
|
||||
cliVersion = value;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Database Status Data
|
||||
private bool dbIsConnected = false;
|
||||
private bool dbFileExists = false;
|
||||
private bool dbIsSyncing = false;
|
||||
private int dbUndoCount = 0;
|
||||
private int dbRedoCount = 0;
|
||||
|
||||
[CreateProperty]
|
||||
public bool DbIsConnected
|
||||
{
|
||||
get => dbIsConnected;
|
||||
set
|
||||
{
|
||||
if (dbIsConnected == value) return;
|
||||
dbIsConnected = value;
|
||||
Notify();
|
||||
Notify(nameof(DbStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string DbStatusText => DbIsConnected ? "✅ Connected" : "❌ Not Connected";
|
||||
|
||||
[CreateProperty]
|
||||
public bool DbFileExists
|
||||
{
|
||||
get => dbFileExists;
|
||||
set
|
||||
{
|
||||
if (dbFileExists == value) return;
|
||||
dbFileExists = value;
|
||||
Notify();
|
||||
Notify(nameof(DbFileExistsText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string DbFileExistsText => DbFileExists ? "✅ Created" : "❌ Not Created";
|
||||
|
||||
[CreateProperty]
|
||||
public bool DbIsSyncing
|
||||
{
|
||||
get => dbIsSyncing;
|
||||
set
|
||||
{
|
||||
if (dbIsSyncing == value) return;
|
||||
dbIsSyncing = value;
|
||||
Notify();
|
||||
Notify(nameof(DbSyncStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string DbSyncStatusText => DbIsSyncing ? "✅ Running" : "🚧 (구현예정)";
|
||||
|
||||
[CreateProperty]
|
||||
public int DbUndoCount
|
||||
{
|
||||
get => dbUndoCount;
|
||||
set
|
||||
{
|
||||
if (dbUndoCount == value) return;
|
||||
dbUndoCount = value;
|
||||
Notify();
|
||||
Notify(nameof(DbUndoCountText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string DbUndoCountText => $"🔢 {DbUndoCount}";
|
||||
|
||||
[CreateProperty]
|
||||
public int DbRedoCount
|
||||
{
|
||||
get => dbRedoCount;
|
||||
set
|
||||
{
|
||||
if (dbRedoCount == value) return;
|
||||
dbRedoCount = value;
|
||||
Notify();
|
||||
Notify(nameof(DbRedoCountText));
|
||||
}
|
||||
}
|
||||
|
||||
[CreateProperty]
|
||||
public string DbRedoCountText => $"🔢 {DbRedoCount}";
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7289343a5ced3c4a994e9dc51df2763
|
||||
1305
skills/assets/unity-package/Editor/EditorServerWindowDatabase.cs
Normal file
1305
skills/assets/unity-package/Editor/EditorServerWindowDatabase.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61c265daf09fed041bd8e2817b7aca18
|
||||
232
skills/assets/unity-package/Editor/EditorToolbarExtension.cs
Normal file
232
skills/assets/unity-package/Editor/EditorToolbarExtension.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditorToolkit.Editor.Server;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
|
||||
namespace UnityEditorToolkit.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity Editor Toolbar에 서버 및 DB 연결 상태를 표시 (Reflection 기반)
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class EditorToolbarExtension
|
||||
{
|
||||
private static VisualElement toolbarRoot;
|
||||
private static VisualElement customToolbarLeft;
|
||||
private static Label serverStatusLabel;
|
||||
private static Label dbStatusLabel;
|
||||
private static VisualElement statusContainer;
|
||||
|
||||
static EditorToolbarExtension()
|
||||
{
|
||||
EditorApplication.update -= OnUpdate;
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
private static void TryInitialize()
|
||||
{
|
||||
if (toolbarRoot != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var toolbarType = typeof(UnityEditor.Editor).Assembly.GetType("UnityEditor.Toolbar");
|
||||
if (toolbarType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var toolbarObj = toolbarType.GetField("get").GetValue(null);
|
||||
if (toolbarObj == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
toolbarRoot = (VisualElement)toolbarType.GetField("m_Root",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(toolbarObj);
|
||||
|
||||
if (toolbarRoot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// PlayModeButtons 바로 앞에 삽입하기 위해 ToolbarZonePlayMode 찾기
|
||||
var playModeZone = toolbarRoot.Q("ToolbarZonePlayMode");
|
||||
|
||||
customToolbarLeft = new VisualElement
|
||||
{
|
||||
name = "unity-editor-toolkit-toolbar",
|
||||
style =
|
||||
{
|
||||
flexDirection = FlexDirection.Row,
|
||||
alignItems = Align.Center,
|
||||
marginRight = 8,
|
||||
},
|
||||
};
|
||||
|
||||
if (playModeZone != null)
|
||||
{
|
||||
// ToolbarZonePlayMode의 맨 앞에 삽입
|
||||
playModeZone.Insert(0, customToolbarLeft);
|
||||
}
|
||||
else
|
||||
{
|
||||
// PlayModeZone을 못 찾으면 ToolbarZoneLeftAlign에 추가
|
||||
var toolbarLeft = toolbarRoot.Q("ToolbarZoneLeftAlign");
|
||||
if (toolbarLeft != null)
|
||||
{
|
||||
toolbarLeft.Add(customToolbarLeft);
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
InitializeServerStatus();
|
||||
}
|
||||
|
||||
private static void InitializeServerStatus()
|
||||
{
|
||||
// 클릭 가능한 컨테이너 (전체가 버튼처럼 동작)
|
||||
statusContainer = new VisualElement
|
||||
{
|
||||
name = "unity-editor-toolkit-status",
|
||||
style =
|
||||
{
|
||||
flexDirection = FlexDirection.Row,
|
||||
alignItems = Align.Center,
|
||||
paddingLeft = 6,
|
||||
paddingRight = 6,
|
||||
paddingTop = 2,
|
||||
paddingBottom = 2,
|
||||
backgroundColor = new Color(0.2f, 0.2f, 0.2f, 0.3f),
|
||||
borderTopLeftRadius = 3,
|
||||
borderTopRightRadius = 3,
|
||||
borderBottomLeftRadius = 3,
|
||||
borderBottomRightRadius = 3,
|
||||
},
|
||||
};
|
||||
|
||||
// 마우스 이벤트 추가
|
||||
statusContainer.RegisterCallback<MouseDownEvent>(evt =>
|
||||
{
|
||||
if (evt.button == 0) // 왼쪽 클릭
|
||||
{
|
||||
ShowWindowMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// 마우스 오버 효과
|
||||
statusContainer.RegisterCallback<MouseEnterEvent>(evt =>
|
||||
{
|
||||
statusContainer.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.5f);
|
||||
});
|
||||
|
||||
statusContainer.RegisterCallback<MouseLeaveEvent>(evt =>
|
||||
{
|
||||
statusContainer.style.backgroundColor = new Color(0.2f, 0.2f, 0.2f, 0.3f);
|
||||
});
|
||||
|
||||
// 서버 상태 라벨
|
||||
serverStatusLabel = new Label("●")
|
||||
{
|
||||
name = "server-status-label",
|
||||
style =
|
||||
{
|
||||
fontSize = 11,
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
marginRight = 5,
|
||||
},
|
||||
};
|
||||
|
||||
// DB 상태 라벨
|
||||
dbStatusLabel = new Label("●")
|
||||
{
|
||||
name = "db-status-label",
|
||||
style =
|
||||
{
|
||||
fontSize = 11,
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
marginRight = 3,
|
||||
},
|
||||
};
|
||||
|
||||
// 드롭다운 화살표
|
||||
var dropdownArrow = new Label("▼")
|
||||
{
|
||||
style =
|
||||
{
|
||||
fontSize = 8,
|
||||
color = new Color(0.8f, 0.8f, 0.8f, 1f),
|
||||
},
|
||||
};
|
||||
|
||||
statusContainer.Add(serverStatusLabel);
|
||||
statusContainer.Add(dbStatusLabel);
|
||||
statusContainer.Add(dropdownArrow);
|
||||
customToolbarLeft.Add(statusContainer);
|
||||
}
|
||||
|
||||
private static void OnUpdate()
|
||||
{
|
||||
TryInitialize();
|
||||
UpdateServerStatus();
|
||||
}
|
||||
|
||||
private static void UpdateServerStatus()
|
||||
{
|
||||
if (serverStatusLabel == null || dbStatusLabel == null || statusContainer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 상태 업데이트
|
||||
var server = EditorWebSocketServer.Instance;
|
||||
bool serverIsRunning = server != null && server.IsRunning;
|
||||
|
||||
if (serverIsRunning)
|
||||
{
|
||||
serverStatusLabel.text = $"● {server.Port}";
|
||||
serverStatusLabel.tooltip = $"WebSocket Server: Running\nPort: {server.Port}\nClients: {server.ConnectedClients}";
|
||||
serverStatusLabel.style.color = new Color(0.3f, 1f, 0.3f);
|
||||
}
|
||||
else
|
||||
{
|
||||
serverStatusLabel.text = "●";
|
||||
serverStatusLabel.tooltip = "WebSocket Server: Stopped";
|
||||
serverStatusLabel.style.color = new Color(1f, 0.3f, 0.3f);
|
||||
}
|
||||
|
||||
// DB 상태 업데이트
|
||||
bool dbIsConnected = DatabaseManager.Instance != null && DatabaseManager.Instance.IsConnected;
|
||||
|
||||
if (dbIsConnected)
|
||||
{
|
||||
dbStatusLabel.text = "● DB";
|
||||
dbStatusLabel.tooltip = "Database: Connected";
|
||||
dbStatusLabel.style.color = new Color(0.3f, 1f, 0.3f);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbStatusLabel.text = "● DB";
|
||||
dbStatusLabel.tooltip = "Database: Disconnected";
|
||||
dbStatusLabel.style.color = new Color(1f, 0.3f, 0.3f);
|
||||
}
|
||||
|
||||
// 전체 컨테이너 tooltip (종합 정보)
|
||||
string serverStatus = serverIsRunning ? $"Running (:{server.Port})" : "Stopped";
|
||||
string dbStatus = dbIsConnected ? "Connected" : "Disconnected";
|
||||
statusContainer.tooltip = $"Unity Editor Toolkit\n\nServer: {serverStatus}\nDatabase: {dbStatus}\n\nClick to open menu";
|
||||
}
|
||||
|
||||
private static void ShowWindowMenu()
|
||||
{
|
||||
var menu = new GenericMenu();
|
||||
menu.AddItem(new GUIContent("Open Unity Editor Toolkit"), false, () => EditorServerWindow.ShowWindow());
|
||||
menu.ShowAsContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbcf4459cf5d8634389a45be6ef10bc8
|
||||
8
skills/assets/unity-package/Editor/Handlers.meta
Normal file
8
skills/assets/unity-package/Editor/Handlers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c86997cf856c7043a54da66e5f0919f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
511
skills/assets/unity-package/Editor/Handlers/AnalyticsHandler.cs
Normal file
511
skills/assets/unity-package/Editor/Handlers/AnalyticsHandler.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Analytics and caching
|
||||
/// </summary>
|
||||
public class AnalyticsHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Analytics";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "GetProjectStats":
|
||||
return HandleGetProjectStats(request);
|
||||
case "GetSceneStats":
|
||||
return HandleGetSceneStats(request);
|
||||
case "SetCache":
|
||||
return HandleSetCache(request);
|
||||
case "GetCache":
|
||||
return HandleGetCache(request);
|
||||
case "ClearCache":
|
||||
return HandleClearCache(request);
|
||||
case "ListCache":
|
||||
return HandleListCache(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get project-wide statistics
|
||||
/// </summary>
|
||||
private object HandleGetProjectStats(JsonRpcRequest request)
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = "project_stats";
|
||||
var cacheResult = GetCacheData(connection, cacheKey);
|
||||
|
||||
if (cacheResult != null)
|
||||
{
|
||||
return JsonUtility.FromJson<ProjectStatsResult>(cacheResult);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
int totalScenes = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM scenes");
|
||||
int totalObjects = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM gameobjects WHERE is_deleted = 0");
|
||||
int totalComponents = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM components");
|
||||
int totalTransforms = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM transforms");
|
||||
int totalSnapshots = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM snapshots");
|
||||
int commandHistoryCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM command_history");
|
||||
|
||||
// Get most used components
|
||||
var componentsSql = @"
|
||||
SELECT component_type, COUNT(*) as count
|
||||
FROM components
|
||||
GROUP BY component_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
";
|
||||
var componentStats = connection.Query<ComponentStatRecord>(componentsSql);
|
||||
|
||||
var topComponents = componentStats.Select(c => new ComponentStat
|
||||
{
|
||||
componentType = c.component_type,
|
||||
count = c.count
|
||||
}).ToList();
|
||||
|
||||
var result = new ProjectStatsResult
|
||||
{
|
||||
success = true,
|
||||
totalScenes = totalScenes,
|
||||
totalObjects = totalObjects,
|
||||
totalComponents = totalComponents,
|
||||
totalTransforms = totalTransforms,
|
||||
totalSnapshots = totalSnapshots,
|
||||
commandHistoryCount = commandHistoryCount,
|
||||
topComponents = topComponents
|
||||
};
|
||||
|
||||
// Cache result
|
||||
var jsonData = JsonUtility.ToJson(result);
|
||||
SetCacheData(connection, cacheKey, jsonData, 3600); // Cache for 1 hour
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current scene statistics
|
||||
/// </summary>
|
||||
private object HandleGetSceneStats(JsonRpcRequest request)
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
var cacheKey = $"scene_stats_{scene.path}";
|
||||
|
||||
// Check cache
|
||||
var cacheResult = GetCacheData(connection, cacheKey);
|
||||
if (cacheResult != null)
|
||||
{
|
||||
return JsonUtility.FromJson<SceneStatsResult>(cacheResult);
|
||||
}
|
||||
|
||||
// Get scene ID
|
||||
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
|
||||
if (sceneIds.Count() == 0)
|
||||
{
|
||||
return new SceneStatsResult
|
||||
{
|
||||
success = true,
|
||||
sceneName = scene.name,
|
||||
scenePath = scene.path,
|
||||
objectCount = 0,
|
||||
componentCount = 0,
|
||||
snapshotCount = 0,
|
||||
message = "Scene not synced to database"
|
||||
};
|
||||
}
|
||||
|
||||
int sceneId = sceneIds.First().scene_id;
|
||||
|
||||
// Calculate stats
|
||||
int objectCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM gameobjects WHERE scene_id = ? AND is_deleted = 0", sceneId);
|
||||
int componentCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM components WHERE object_id IN (SELECT object_id FROM gameobjects WHERE scene_id = ?)", sceneId);
|
||||
int snapshotCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM snapshots WHERE scene_id = ?", sceneId);
|
||||
int transformHistoryCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM transforms WHERE object_id IN (SELECT object_id FROM gameobjects WHERE scene_id = ?)", sceneId);
|
||||
|
||||
var result = new SceneStatsResult
|
||||
{
|
||||
success = true,
|
||||
sceneName = scene.name,
|
||||
scenePath = scene.path,
|
||||
sceneId = sceneId,
|
||||
objectCount = objectCount,
|
||||
componentCount = componentCount,
|
||||
snapshotCount = snapshotCount,
|
||||
transformHistoryCount = transformHistoryCount,
|
||||
message = "Scene statistics retrieved successfully"
|
||||
};
|
||||
|
||||
// Cache result
|
||||
var jsonData = JsonUtility.ToJson(result);
|
||||
SetCacheData(connection, cacheKey, jsonData, 300); // Cache for 5 minutes
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set cache data
|
||||
/// </summary>
|
||||
private object HandleSetCache(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetCacheParams>(request, "key");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
int ttl = param.ttl > 0 ? param.ttl : 3600; // Default 1 hour
|
||||
SetCacheData(connection, param.key, param.data, ttl);
|
||||
|
||||
return new CacheResult
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = $"Cache set successfully (TTL: {ttl}s)"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get cache data
|
||||
/// </summary>
|
||||
private object HandleGetCache(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<GetCacheParams>(request, "key");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
var data = GetCacheData(connection, param.key);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return new GetCacheResult
|
||||
{
|
||||
success = false,
|
||||
key = param.key,
|
||||
data = null,
|
||||
message = "Cache not found or expired"
|
||||
};
|
||||
}
|
||||
|
||||
return new GetCacheResult
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
data = data,
|
||||
message = "Cache retrieved successfully"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear cache
|
||||
/// </summary>
|
||||
private object HandleClearCache(JsonRpcRequest request)
|
||||
{
|
||||
var param = request.GetParams<ClearCacheParams>();
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
int deletedCount;
|
||||
|
||||
if (param != null && !string.IsNullOrEmpty(param.key))
|
||||
{
|
||||
// Clear specific key
|
||||
deletedCount = connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", param.key);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear all cache
|
||||
deletedCount = connection.Execute("DELETE FROM analytics_cache");
|
||||
}
|
||||
|
||||
return new ClearCacheResult
|
||||
{
|
||||
success = true,
|
||||
deletedCount = deletedCount,
|
||||
message = $"Cleared {deletedCount} cache entries"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all cache entries
|
||||
/// </summary>
|
||||
private object HandleListCache(JsonRpcRequest request)
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
var sql = @"
|
||||
SELECT cache_id, cache_key, expires_at, created_at
|
||||
FROM analytics_cache
|
||||
ORDER BY created_at DESC
|
||||
";
|
||||
var records = connection.Query<CacheListRecord>(sql);
|
||||
|
||||
var entries = records.Select(r => new CacheEntry
|
||||
{
|
||||
cacheId = r.cache_id,
|
||||
cacheKey = r.cache_key,
|
||||
expiresAt = r.expires_at,
|
||||
createdAt = r.created_at,
|
||||
isExpired = IsExpired(r.expires_at)
|
||||
}).ToList();
|
||||
|
||||
return new ListCacheResult
|
||||
{
|
||||
success = true,
|
||||
count = entries.Count,
|
||||
entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetCacheData(SQLite.SQLiteConnection connection, string key, string data, int ttlSeconds)
|
||||
{
|
||||
var expiresAt = DateTime.Now.AddSeconds(ttlSeconds).ToString("O");
|
||||
|
||||
// Delete existing
|
||||
connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", key);
|
||||
|
||||
// Insert new
|
||||
var sql = @"
|
||||
INSERT INTO analytics_cache (cache_key, cache_data, expires_at, created_at)
|
||||
VALUES (?, ?, ?, datetime('now', 'localtime'))
|
||||
";
|
||||
connection.Execute(sql, key, data, expiresAt);
|
||||
}
|
||||
|
||||
private string GetCacheData(SQLite.SQLiteConnection connection, string key)
|
||||
{
|
||||
var sql = "SELECT cache_data, expires_at FROM analytics_cache WHERE cache_key = ?";
|
||||
var records = connection.Query<CacheDataRecord>(sql, key);
|
||||
|
||||
if (records.Count() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var record = records.First();
|
||||
|
||||
// Check expiration
|
||||
if (IsExpired(record.expires_at))
|
||||
{
|
||||
// Delete expired cache
|
||||
connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return record.cache_data;
|
||||
}
|
||||
|
||||
private bool IsExpired(string expiresAt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expiresAt))
|
||||
{
|
||||
return false; // Never expires
|
||||
}
|
||||
|
||||
DateTime expires;
|
||||
if (DateTime.TryParse(expiresAt, out expires))
|
||||
{
|
||||
return DateTime.Now > expires;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Classes
|
||||
|
||||
private class SceneIdRecord
|
||||
{
|
||||
public int scene_id { get; set; }
|
||||
}
|
||||
|
||||
private class ComponentStatRecord
|
||||
{
|
||||
public string component_type { get; set; }
|
||||
public int count { get; set; }
|
||||
}
|
||||
|
||||
private class CacheDataRecord
|
||||
{
|
||||
public string cache_data { get; set; }
|
||||
public string expires_at { get; set; }
|
||||
}
|
||||
|
||||
private class CacheListRecord
|
||||
{
|
||||
public int cache_id { get; set; }
|
||||
public string cache_key { get; set; }
|
||||
public string expires_at { get; set; }
|
||||
public string created_at { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetCacheParams
|
||||
{
|
||||
public string key;
|
||||
public string data;
|
||||
public int ttl = 3600; // Default 1 hour
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetCacheParams
|
||||
{
|
||||
public string key;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ClearCacheParams
|
||||
{
|
||||
public string key;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ComponentStat
|
||||
{
|
||||
public string componentType;
|
||||
public int count;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProjectStatsResult
|
||||
{
|
||||
public bool success;
|
||||
public int totalScenes;
|
||||
public int totalObjects;
|
||||
public int totalComponents;
|
||||
public int totalTransforms;
|
||||
public int totalSnapshots;
|
||||
public int commandHistoryCount;
|
||||
public List<ComponentStat> topComponents;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SceneStatsResult
|
||||
{
|
||||
public bool success;
|
||||
public string sceneName;
|
||||
public string scenePath;
|
||||
public int sceneId;
|
||||
public int objectCount;
|
||||
public int componentCount;
|
||||
public int snapshotCount;
|
||||
public int transformHistoryCount;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class CacheResult
|
||||
{
|
||||
public bool success;
|
||||
public string key;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetCacheResult
|
||||
{
|
||||
public bool success;
|
||||
public string key;
|
||||
public string data;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ClearCacheResult
|
||||
{
|
||||
public bool success;
|
||||
public int deletedCount;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class CacheEntry
|
||||
{
|
||||
public int cacheId;
|
||||
public string cacheKey;
|
||||
public string expiresAt;
|
||||
public string createdAt;
|
||||
public bool isExpired;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ListCacheResult
|
||||
{
|
||||
public bool success;
|
||||
public int count;
|
||||
public List<CacheEntry> entries;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
566
skills/assets/unity-package/Editor/Handlers/AnimationHandler.cs
Normal file
566
skills/assets/unity-package/Editor/Handlers/AnimationHandler.cs
Normal file
@@ -0,0 +1,566 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Animation commands
|
||||
/// </summary>
|
||||
public class AnimationHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Animation";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Play":
|
||||
return HandlePlay(request);
|
||||
case "Stop":
|
||||
return HandleStop(request);
|
||||
case "GetState":
|
||||
return HandleGetState(request);
|
||||
case "SetParameter":
|
||||
return HandleSetParameter(request);
|
||||
case "GetParameter":
|
||||
return HandleGetParameter(request);
|
||||
case "GetParameters":
|
||||
return HandleGetParameters(request);
|
||||
case "SetTrigger":
|
||||
return HandleSetTrigger(request);
|
||||
case "ResetTrigger":
|
||||
return HandleResetTrigger(request);
|
||||
case "CrossFade":
|
||||
return HandleCrossFade(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandlePlay(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationPlayParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
// Try Animator first (newer system)
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(param.stateName))
|
||||
{
|
||||
animator.Play(param.stateName, param.layer ?? 0, param.normalizedTime ?? 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
animator.enabled = true;
|
||||
animator.speed = param.speed ?? 1f;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animator",
|
||||
stateName = param.stateName ?? "Default",
|
||||
message = "Animation playing"
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to legacy Animation component
|
||||
var animation = obj.GetComponent<Animation>();
|
||||
if (animation != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(param.clipName))
|
||||
{
|
||||
animation.Play(param.clipName);
|
||||
}
|
||||
else
|
||||
{
|
||||
animation.Play();
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animation",
|
||||
clipName = param.clipName ?? "Default",
|
||||
message = "Animation playing"
|
||||
};
|
||||
}
|
||||
|
||||
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
private object HandleStop(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationStopParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
// Try Animator first
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
if (param.resetToDefault)
|
||||
{
|
||||
animator.Rebind();
|
||||
animator.Update(0f);
|
||||
}
|
||||
animator.speed = 0f;
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animator",
|
||||
message = "Animation stopped"
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to legacy Animation
|
||||
var animation = obj.GetComponent<Animation>();
|
||||
if (animation != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(param.clipName))
|
||||
{
|
||||
animation.Stop(param.clipName);
|
||||
}
|
||||
else
|
||||
{
|
||||
animation.Stop();
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animation",
|
||||
message = "Animation stopped"
|
||||
};
|
||||
}
|
||||
|
||||
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
private object HandleGetState(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationStateParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
// Try Animator first
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
int layer = param.layer ?? 0;
|
||||
var stateInfo = animator.GetCurrentAnimatorStateInfo(layer);
|
||||
var clipInfo = animator.GetCurrentAnimatorClipInfo(layer);
|
||||
|
||||
string currentClipName = "None";
|
||||
if (clipInfo.Length > 0 && clipInfo[0].clip != null)
|
||||
{
|
||||
currentClipName = clipInfo[0].clip.name;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animator",
|
||||
enabled = animator.enabled,
|
||||
speed = animator.speed,
|
||||
layer = layer,
|
||||
currentState = new
|
||||
{
|
||||
fullPathHash = stateInfo.fullPathHash,
|
||||
shortNameHash = stateInfo.shortNameHash,
|
||||
normalizedTime = stateInfo.normalizedTime,
|
||||
length = stateInfo.length,
|
||||
speed = stateInfo.speed,
|
||||
speedMultiplier = stateInfo.speedMultiplier,
|
||||
isLooping = stateInfo.loop,
|
||||
clipName = currentClipName
|
||||
},
|
||||
isInTransition = animator.IsInTransition(layer),
|
||||
hasRootMotion = animator.hasRootMotion,
|
||||
layerCount = animator.layerCount,
|
||||
parameterCount = animator.parameterCount
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to legacy Animation
|
||||
var animation = obj.GetComponent<Animation>();
|
||||
if (animation != null)
|
||||
{
|
||||
var clips = new List<object>();
|
||||
foreach (AnimationState state in animation)
|
||||
{
|
||||
clips.Add(new
|
||||
{
|
||||
name = state.name,
|
||||
length = state.length,
|
||||
normalizedTime = state.normalizedTime,
|
||||
speed = state.speed,
|
||||
weight = state.weight,
|
||||
enabled = state.enabled,
|
||||
wrapMode = state.wrapMode.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
type = "Animation",
|
||||
isPlaying = animation.isPlaying,
|
||||
clipCount = animation.GetClipCount(),
|
||||
clips = clips
|
||||
};
|
||||
}
|
||||
|
||||
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
private object HandleSetParameter(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationParameterParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
// Find parameter type
|
||||
AnimatorControllerParameterType? paramType = null;
|
||||
foreach (var p in animator.parameters)
|
||||
{
|
||||
if (p.name == param.parameterName)
|
||||
{
|
||||
paramType = p.type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!paramType.HasValue)
|
||||
{
|
||||
throw new Exception($"Parameter not found: {param.parameterName}");
|
||||
}
|
||||
|
||||
switch (paramType.Value)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
animator.SetFloat(param.parameterName, Convert.ToSingle(param.value));
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
animator.SetInteger(param.parameterName, Convert.ToInt32(param.value));
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
animator.SetBool(param.parameterName, Convert.ToBoolean(param.value));
|
||||
break;
|
||||
case AnimatorControllerParameterType.Trigger:
|
||||
if (Convert.ToBoolean(param.value))
|
||||
animator.SetTrigger(param.parameterName);
|
||||
else
|
||||
animator.ResetTrigger(param.parameterName);
|
||||
break;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
parameterName = param.parameterName,
|
||||
parameterType = paramType.Value.ToString(),
|
||||
value = param.value
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleGetParameter(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationGetParameterParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
// Find parameter
|
||||
AnimatorControllerParameter foundParam = null;
|
||||
foreach (var p in animator.parameters)
|
||||
{
|
||||
if (p.name == param.parameterName)
|
||||
{
|
||||
foundParam = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundParam == null)
|
||||
{
|
||||
throw new Exception($"Parameter not found: {param.parameterName}");
|
||||
}
|
||||
|
||||
object value = null;
|
||||
switch (foundParam.type)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
value = animator.GetFloat(param.parameterName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
value = animator.GetInteger(param.parameterName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
value = animator.GetBool(param.parameterName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Trigger:
|
||||
value = "Trigger (no value)";
|
||||
break;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
parameterName = param.parameterName,
|
||||
parameterType = foundParam.type.ToString(),
|
||||
value = value
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleGetParameters(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationBaseParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var parameters = new List<object>();
|
||||
foreach (var p in animator.parameters)
|
||||
{
|
||||
object value = null;
|
||||
switch (p.type)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
value = animator.GetFloat(p.name);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
value = animator.GetInteger(p.name);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
value = animator.GetBool(p.name);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Trigger:
|
||||
value = null;
|
||||
break;
|
||||
}
|
||||
|
||||
parameters.Add(new
|
||||
{
|
||||
name = p.name,
|
||||
type = p.type.ToString(),
|
||||
value = value,
|
||||
defaultFloat = p.defaultFloat,
|
||||
defaultInt = p.defaultInt,
|
||||
defaultBool = p.defaultBool
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
count = parameters.Count,
|
||||
parameters = parameters
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleSetTrigger(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationTriggerParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
animator.SetTrigger(param.triggerName);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
triggerName = param.triggerName,
|
||||
message = "Trigger set"
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleResetTrigger(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationTriggerParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
animator.ResetTrigger(param.triggerName);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
triggerName = param.triggerName,
|
||||
message = "Trigger reset"
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleCrossFade(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AnimationCrossFadeParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var animator = obj.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
throw new Exception($"No Animator component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
animator.CrossFade(
|
||||
param.stateName,
|
||||
param.transitionDuration ?? 0.25f,
|
||||
param.layer ?? 0,
|
||||
param.normalizedTimeOffset ?? 0f
|
||||
);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
stateName = param.stateName,
|
||||
transitionDuration = param.transitionDuration ?? 0.25f,
|
||||
message = "CrossFade started"
|
||||
};
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class AnimationBaseParams
|
||||
{
|
||||
public string gameObject;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationPlayParams : AnimationBaseParams
|
||||
{
|
||||
public string stateName; // For Animator
|
||||
public string clipName; // For legacy Animation
|
||||
public int? layer;
|
||||
public float? normalizedTime;
|
||||
public float? speed;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationStopParams : AnimationBaseParams
|
||||
{
|
||||
public string clipName;
|
||||
public bool resetToDefault;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationStateParams : AnimationBaseParams
|
||||
{
|
||||
public int? layer;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationParameterParams : AnimationBaseParams
|
||||
{
|
||||
public string parameterName;
|
||||
public object value;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationGetParameterParams : AnimationBaseParams
|
||||
{
|
||||
public string parameterName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationTriggerParams : AnimationBaseParams
|
||||
{
|
||||
public string triggerName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimationCrossFadeParams : AnimationBaseParams
|
||||
{
|
||||
public string stateName;
|
||||
public float? transitionDuration;
|
||||
public int? layer;
|
||||
public float? normalizedTimeOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95678540617b4de439b05e447c7b1a8a
|
||||
1434
skills/assets/unity-package/Editor/Handlers/AssetHandler.cs
Normal file
1434
skills/assets/unity-package/Editor/Handlers/AssetHandler.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13f4614dd3790eb418c810975f0eb5cc
|
||||
210
skills/assets/unity-package/Editor/Handlers/BaseHandler.cs
Normal file
210
skills/assets/unity-package/Editor/Handlers/BaseHandler.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Base handler for JSON-RPC commands
|
||||
/// </summary>
|
||||
public abstract class BaseHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject 캐시 (WeakReference 사용하여 메모리 누수 방지)
|
||||
/// </summary>
|
||||
private static Dictionary<string, System.WeakReference> gameObjectCache = new Dictionary<string, System.WeakReference>();
|
||||
private static readonly object cacheLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Handler category (e.g., "GameObject", "Transform")
|
||||
/// </summary>
|
||||
public abstract string Category { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Handle JSON-RPC request
|
||||
/// </summary>
|
||||
/// <param name="request">JSON-RPC request</param>
|
||||
/// <returns>Response object or null for error</returns>
|
||||
public object Handle(JsonRpcRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate request
|
||||
if (request == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request), "Request cannot be null");
|
||||
}
|
||||
|
||||
// Validate method name
|
||||
string fullMethod = request.Method;
|
||||
if (string.IsNullOrWhiteSpace(fullMethod))
|
||||
{
|
||||
throw new ArgumentException("Method name cannot be null or empty", nameof(request.Method));
|
||||
}
|
||||
|
||||
// Validate method belongs to this handler category
|
||||
if (!fullMethod.StartsWith(Category + "."))
|
||||
{
|
||||
throw new ArgumentException($"Invalid method for {Category} handler: {fullMethod}");
|
||||
}
|
||||
|
||||
string methodName = fullMethod.Substring(Category.Length + 1);
|
||||
|
||||
// Validate extracted method name
|
||||
if (string.IsNullOrWhiteSpace(methodName))
|
||||
{
|
||||
throw new ArgumentException($"Method name is empty after removing category prefix: {fullMethod}");
|
||||
}
|
||||
|
||||
// Route to specific handler method
|
||||
return HandleMethod(methodName, request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError(Category, $"Handler error: {ex.Message}\n{ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle specific method (must be implemented by subclass)
|
||||
/// </summary>
|
||||
protected abstract object HandleMethod(string method, JsonRpcRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Validate required parameter
|
||||
/// </summary>
|
||||
protected T ValidateParam<T>(JsonRpcRequest request, string paramName) where T : class
|
||||
{
|
||||
var paramsObj = request.GetParams<T>();
|
||||
if (paramsObj == null)
|
||||
{
|
||||
throw new Exception($"Missing or invalid parameter: {paramName}");
|
||||
}
|
||||
return paramsObj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find GameObject by name or path (캐싱 적용)
|
||||
/// </summary>
|
||||
public UnityEngine.GameObject FindGameObject(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 캐시 확인
|
||||
lock (cacheLock)
|
||||
{
|
||||
if (gameObjectCache.TryGetValue(name, out var weakRef) && weakRef.IsAlive)
|
||||
{
|
||||
var cachedObj = weakRef.Target as UnityEngine.GameObject;
|
||||
if (cachedObj != null && cachedObj.scene.IsValid())
|
||||
{
|
||||
return cachedObj;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 캐시 무효화 (객체가 파괴됨)
|
||||
gameObjectCache.Remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct find first (빠른 검색)
|
||||
var obj = UnityEngine.GameObject.Find(name);
|
||||
if (obj != null)
|
||||
{
|
||||
CacheGameObject(name, obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Try finding in all objects (including inactive) - 비용이 큼
|
||||
var allObjects = UnityEngine.Resources.FindObjectsOfTypeAll<UnityEngine.GameObject>();
|
||||
foreach (var go in allObjects)
|
||||
{
|
||||
if (go.name == name || GetGameObjectPath(go) == name)
|
||||
{
|
||||
// Make sure it's a scene object, not asset
|
||||
if (go.scene.IsValid())
|
||||
{
|
||||
CacheGameObject(name, go);
|
||||
return go;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject를 캐시에 추가
|
||||
/// </summary>
|
||||
private void CacheGameObject(string name, UnityEngine.GameObject obj)
|
||||
{
|
||||
lock (cacheLock)
|
||||
{
|
||||
gameObjectCache[name] = new System.WeakReference(obj);
|
||||
|
||||
// 캐시 크기 제한 (최대 100개)
|
||||
if (gameObjectCache.Count > 100)
|
||||
{
|
||||
// 만료된(파괴된) 캐시 항목 제거
|
||||
var toRemove = new List<string>();
|
||||
foreach (var kvp in gameObjectCache)
|
||||
{
|
||||
if (!kvp.Value.IsAlive)
|
||||
{
|
||||
toRemove.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
if (toRemove.Count > 0)
|
||||
{
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
gameObjectCache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 여전히 캐시 크기가 100개를 초과하면, 일부 항목을 제거하여 공간 확보
|
||||
while (gameObjectCache.Count > 100)
|
||||
{
|
||||
// 가장 간단한 방법으로 첫 번째 항목 제거
|
||||
// 더 나은 방법은 LRU(Least Recently Used) 정책을 구현하는 것입니다
|
||||
var keyToRemove = gameObjectCache.Keys.First();
|
||||
gameObjectCache.Remove(keyToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캐시 비우기 (테스트용 또는 메모리 정리)
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
lock (cacheLock)
|
||||
{
|
||||
gameObjectCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get full path of GameObject in hierarchy
|
||||
/// </summary>
|
||||
protected string GetGameObjectPath(UnityEngine.GameObject obj)
|
||||
{
|
||||
string path = obj.name;
|
||||
var parent = obj.transform.parent;
|
||||
while (parent != null)
|
||||
{
|
||||
path = parent.name + "/" + path;
|
||||
parent = parent.parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bd9849f6e036df4787b825d4a713960
|
||||
160
skills/assets/unity-package/Editor/Handlers/ChainHandler.cs
Normal file
160
skills/assets/unity-package/Editor/Handlers/ChainHandler.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Chain commands
|
||||
/// Executes multiple commands sequentially
|
||||
/// </summary>
|
||||
public class ChainHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Chain";
|
||||
|
||||
private Dictionary<string, BaseHandler> handlers;
|
||||
|
||||
public void SetHandlers(Dictionary<string, BaseHandler> handlers)
|
||||
{
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Execute":
|
||||
return HandleExecute(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleExecute(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<ExecuteParams>(request, "commands");
|
||||
|
||||
if (param.commands == null || param.commands.Length == 0)
|
||||
{
|
||||
throw new Exception("Commands array is required and cannot be empty");
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("ChainHandler", $"Executing {param.commands.Length} command(s) sequentially...");
|
||||
|
||||
var results = new List<object>();
|
||||
double totalElapsed = 0;
|
||||
|
||||
for (int i = 0; i < param.commands.Length; i++)
|
||||
{
|
||||
var cmd = param.commands[i];
|
||||
double startTime = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("ChainHandler", $"[{i + 1}/{param.commands.Length}] Executing: {cmd.method}");
|
||||
|
||||
// Parse method to get category
|
||||
string category = GetCategory(cmd.method);
|
||||
|
||||
if (!handlers.ContainsKey(category))
|
||||
{
|
||||
throw new Exception($"Unknown command category: {category}");
|
||||
}
|
||||
|
||||
// Create a new request for this command
|
||||
var chainedRequest = new JsonRpcRequest
|
||||
{
|
||||
Id = $"{request.Id}:chain:{i}",
|
||||
Method = cmd.method,
|
||||
Params = cmd.parameters != null ? JToken.FromObject(cmd.parameters) : null
|
||||
};
|
||||
|
||||
// Execute the command
|
||||
var handler = handlers[category];
|
||||
var result = handler.Handle(chainedRequest);
|
||||
|
||||
double elapsed = UnityEditor.EditorApplication.timeSinceStartup - startTime;
|
||||
totalElapsed += elapsed;
|
||||
|
||||
// If result is null, it's a delayed response (not supported in chain)
|
||||
if (result == null)
|
||||
{
|
||||
throw new Exception($"Command '{cmd.method}' returned delayed response, which is not supported in chain execution");
|
||||
}
|
||||
|
||||
results.Add(new
|
||||
{
|
||||
index = i,
|
||||
method = cmd.method,
|
||||
success = true,
|
||||
result = result,
|
||||
elapsed = elapsed
|
||||
});
|
||||
|
||||
ToolkitLogger.LogDebug("ChainHandler", $"[{i + 1}/{param.commands.Length}] Success: {cmd.method} ({elapsed:F3}s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
double elapsed = UnityEditor.EditorApplication.timeSinceStartup - startTime;
|
||||
totalElapsed += elapsed;
|
||||
|
||||
ToolkitLogger.LogError("ChainHandler", $"[{i + 1}/{param.commands.Length}] Failed: {cmd.method} - {ex.Message}");
|
||||
|
||||
// Add error result
|
||||
results.Add(new
|
||||
{
|
||||
index = i,
|
||||
method = cmd.method,
|
||||
success = false,
|
||||
error = ex.Message,
|
||||
elapsed = elapsed
|
||||
});
|
||||
|
||||
// If stopOnError is true, stop execution
|
||||
if (param.stopOnError)
|
||||
{
|
||||
ToolkitLogger.LogWarning("ChainHandler", "Stopping chain execution due to error (stopOnError=true)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
totalCommands = param.commands.Length,
|
||||
executedCommands = results.Count,
|
||||
totalElapsed = totalElapsed,
|
||||
results = results
|
||||
};
|
||||
}
|
||||
|
||||
private string GetCategory(string method)
|
||||
{
|
||||
int dotIndex = method.IndexOf('.');
|
||||
if (dotIndex < 0)
|
||||
{
|
||||
throw new Exception($"Invalid method format: {method}");
|
||||
}
|
||||
return method.Substring(0, dotIndex);
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class ExecuteParams
|
||||
{
|
||||
public CommandEntry[] commands;
|
||||
public bool stopOnError = true;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class CommandEntry
|
||||
{
|
||||
public string method;
|
||||
public object parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c29fc19def9d9354286806ea825e5c4f
|
||||
973
skills/assets/unity-package/Editor/Handlers/ComponentHandler.cs
Normal file
973
skills/assets/unity-package/Editor/Handlers/ComponentHandler.cs
Normal file
@@ -0,0 +1,973 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Component commands (add, remove, enable, disable, get, set, inspect, move-up, move-down, copy)
|
||||
/// </summary>
|
||||
public class ComponentHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Component";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "List":
|
||||
return HandleList(request);
|
||||
case "Add":
|
||||
return HandleAdd(request);
|
||||
case "Remove":
|
||||
return HandleRemove(request);
|
||||
case "SetEnabled":
|
||||
return HandleSetEnabled(request);
|
||||
case "Get":
|
||||
return HandleGet(request);
|
||||
case "Set":
|
||||
return HandleSet(request);
|
||||
case "Inspect":
|
||||
return HandleInspect(request);
|
||||
case "MoveUp":
|
||||
return HandleMoveUp(request);
|
||||
case "MoveDown":
|
||||
return HandleMoveDown(request);
|
||||
case "Copy":
|
||||
return HandleCopy(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all components on a GameObject
|
||||
/// </summary>
|
||||
private object HandleList(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<ListParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
Component[] components = obj.GetComponents<Component>();
|
||||
var list = new List<ComponentInfo>();
|
||||
|
||||
foreach (var comp in components)
|
||||
{
|
||||
if (comp == null) continue;
|
||||
|
||||
// Check enabled state (only for Behaviour types)
|
||||
bool isEnabled = true;
|
||||
if (comp is Behaviour behaviour)
|
||||
{
|
||||
isEnabled = behaviour.enabled;
|
||||
}
|
||||
|
||||
// Skip disabled components if not requested
|
||||
if (!param.includeDisabled && !isEnabled)
|
||||
continue;
|
||||
|
||||
list.Add(new ComponentInfo
|
||||
{
|
||||
type = comp.GetType().Name,
|
||||
fullTypeName = comp.GetType().FullName,
|
||||
enabled = isEnabled,
|
||||
isMonoBehaviour = comp is MonoBehaviour
|
||||
});
|
||||
}
|
||||
|
||||
return new ComponentListResult { count = list.Count, components = list };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a component to a GameObject
|
||||
/// </summary>
|
||||
private object HandleAdd(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<AddParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Check if component already exists
|
||||
if (obj.GetComponent(componentType) != null)
|
||||
{
|
||||
throw new Exception($"Component already exists: {param.componentType}");
|
||||
}
|
||||
|
||||
// Add component
|
||||
Component comp = obj.AddComponent(componentType);
|
||||
|
||||
// Register undo
|
||||
#if UNITY_EDITOR
|
||||
Undo.RegisterCreatedObjectUndo(comp, "Add Component");
|
||||
#endif
|
||||
|
||||
return new ComponentInfo
|
||||
{
|
||||
type = comp.GetType().Name,
|
||||
fullTypeName = comp.GetType().FullName,
|
||||
enabled = comp is Behaviour ? ((Behaviour)comp).enabled : true,
|
||||
isMonoBehaviour = comp is MonoBehaviour
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a component from a GameObject
|
||||
/// </summary>
|
||||
private object HandleRemove(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<RemoveParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Protect Transform
|
||||
if (componentType == typeof(Transform))
|
||||
{
|
||||
throw new Exception("Cannot remove Transform component (required on all GameObjects)");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Remove component
|
||||
#if UNITY_EDITOR
|
||||
Undo.DestroyObjectImmediate(comp);
|
||||
#else
|
||||
Object.DestroyImmediate(comp);
|
||||
#endif
|
||||
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable or disable a component
|
||||
/// </summary>
|
||||
private object HandleSetEnabled(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetEnabledParams>(request, "name, componentType and enabled");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Only Behaviour components support enabled property
|
||||
if (!(comp is Behaviour behaviour))
|
||||
{
|
||||
throw new Exception($"Component {param.componentType} does not support enabled property");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
Undo.RegisterCompleteObjectUndo(comp, param.enabled ? "Enable Component" : "Disable Component");
|
||||
#endif
|
||||
|
||||
behaviour.enabled = param.enabled;
|
||||
|
||||
return new { success = true, enabled = behaviour.enabled };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get component properties
|
||||
/// </summary>
|
||||
private object HandleGet(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<GetParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
SerializedObject so = new SerializedObject(comp);
|
||||
|
||||
// Get specific property
|
||||
if (!string.IsNullOrEmpty(param.property))
|
||||
{
|
||||
SerializedProperty prop = so.FindProperty(param.property);
|
||||
if (prop == null)
|
||||
{
|
||||
throw new Exception($"Property not found: {param.property}");
|
||||
}
|
||||
|
||||
return new PropertyInfo
|
||||
{
|
||||
name = param.property,
|
||||
type = prop.propertyType.ToString(),
|
||||
value = GetPropertyValue(prop)
|
||||
};
|
||||
}
|
||||
|
||||
// Get all properties
|
||||
var properties = new List<PropertyInfo>();
|
||||
SerializedProperty iterator = so.GetIterator();
|
||||
bool enterChildren = true;
|
||||
|
||||
while (iterator.NextVisible(enterChildren))
|
||||
{
|
||||
enterChildren = false;
|
||||
|
||||
// Skip internal properties
|
||||
if (iterator.name.StartsWith("m_"))
|
||||
continue;
|
||||
|
||||
properties.Add(new PropertyInfo
|
||||
{
|
||||
name = iterator.name,
|
||||
type = iterator.propertyType.ToString(),
|
||||
value = GetPropertyValue(iterator)
|
||||
});
|
||||
}
|
||||
|
||||
return new GetComponentResult
|
||||
{
|
||||
componentType = param.componentType,
|
||||
properties = properties
|
||||
};
|
||||
#else
|
||||
throw new Exception("Component.Get is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a component property
|
||||
/// </summary>
|
||||
private object HandleSet(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetParams>(request, "name, componentType, property and value");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
SerializedObject so = new SerializedObject(comp);
|
||||
SerializedProperty prop = so.FindProperty(param.property);
|
||||
|
||||
if (prop == null)
|
||||
{
|
||||
throw new Exception($"Property not found: {param.property}");
|
||||
}
|
||||
|
||||
// Register undo
|
||||
Undo.RegisterCompleteObjectUndo(comp, "Set Component Property");
|
||||
|
||||
object oldValue = GetPropertyValue(prop);
|
||||
|
||||
// Set property value
|
||||
try
|
||||
{
|
||||
SetPropertyValue(prop, param.value);
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set property: {ex.Message}");
|
||||
}
|
||||
|
||||
return new SetPropertyResult
|
||||
{
|
||||
success = true,
|
||||
property = param.property,
|
||||
oldValue = oldValue,
|
||||
newValue = param.value
|
||||
};
|
||||
#else
|
||||
throw new Exception("Component.Set is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspect a component (get all properties and state)
|
||||
/// </summary>
|
||||
private object HandleInspect(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<InspectParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
SerializedObject so = new SerializedObject(comp);
|
||||
|
||||
var properties = new List<PropertyInfo>();
|
||||
SerializedProperty iterator = so.GetIterator();
|
||||
bool enterChildren = true;
|
||||
|
||||
while (iterator.NextVisible(enterChildren))
|
||||
{
|
||||
enterChildren = false;
|
||||
|
||||
// Include all properties for inspection
|
||||
properties.Add(new PropertyInfo
|
||||
{
|
||||
name = iterator.name,
|
||||
type = iterator.propertyType.ToString(),
|
||||
value = GetPropertyValue(iterator)
|
||||
});
|
||||
}
|
||||
|
||||
bool isEnabled = comp is Behaviour ? ((Behaviour)comp).enabled : true;
|
||||
|
||||
return new InspectComponentResult
|
||||
{
|
||||
componentType = param.componentType,
|
||||
fullTypeName = comp.GetType().FullName,
|
||||
enabled = isEnabled,
|
||||
isMonoBehaviour = comp is MonoBehaviour,
|
||||
properties = properties,
|
||||
propertyCount = properties.Count
|
||||
};
|
||||
#else
|
||||
throw new Exception("Component.Inspect is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move a component up in the component list
|
||||
/// </summary>
|
||||
private object HandleMoveUp(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<MoveParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Use ComponentUtility to move component
|
||||
bool success = UnityEditorInternal.ComponentUtility.MoveComponentUp(comp);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
throw new Exception("Cannot move component up (already at top or is Transform)");
|
||||
}
|
||||
|
||||
return new { success = true };
|
||||
#else
|
||||
throw new Exception("Component.MoveUp is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move a component down in the component list
|
||||
/// </summary>
|
||||
private object HandleMoveDown(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<MoveParams>(request, "name and componentType");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component
|
||||
Component comp = obj.GetComponent(componentType);
|
||||
if (comp == null)
|
||||
{
|
||||
throw new Exception($"Component not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Use ComponentUtility to move component
|
||||
bool success = UnityEditorInternal.ComponentUtility.MoveComponentDown(comp);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
throw new Exception("Cannot move component down (already at bottom)");
|
||||
}
|
||||
|
||||
return new { success = true };
|
||||
#else
|
||||
throw new Exception("Component.MoveDown is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy a component from one GameObject to another
|
||||
/// </summary>
|
||||
private object HandleCopy(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<CopyParams>(request, "source, componentType and target");
|
||||
|
||||
var sourceObj = FindGameObject(param.source);
|
||||
if (sourceObj == null)
|
||||
{
|
||||
throw new Exception($"Source GameObject not found: {param.source}");
|
||||
}
|
||||
|
||||
var targetObj = FindGameObject(param.target);
|
||||
if (targetObj == null)
|
||||
{
|
||||
throw new Exception($"Target GameObject not found: {param.target}");
|
||||
}
|
||||
|
||||
// Find component type
|
||||
Type componentType = FindComponentType(param.componentType);
|
||||
if (componentType == null)
|
||||
{
|
||||
throw new Exception($"Component type not found: {param.componentType}");
|
||||
}
|
||||
|
||||
// Find component on source
|
||||
Component sourceComp = sourceObj.GetComponent(componentType);
|
||||
if (sourceComp == null)
|
||||
{
|
||||
throw new Exception($"Component not found on source GameObject: {param.componentType}");
|
||||
}
|
||||
|
||||
// Use ComponentUtility to copy component
|
||||
bool success = UnityEditorInternal.ComponentUtility.CopyComponent(sourceComp);
|
||||
if (!success)
|
||||
{
|
||||
throw new Exception("Failed to copy component");
|
||||
}
|
||||
|
||||
success = UnityEditorInternal.ComponentUtility.PasteComponentAsNew(targetObj);
|
||||
if (!success)
|
||||
{
|
||||
throw new Exception("Failed to paste component");
|
||||
}
|
||||
|
||||
return new { success = true };
|
||||
#else
|
||||
throw new Exception("Component.Copy is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a component type by name (with multiple fallback strategies)
|
||||
/// </summary>
|
||||
private Type FindComponentType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try exact type with namespace
|
||||
var type = Type.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
{
|
||||
return type;
|
||||
}
|
||||
|
||||
// 2. Try UnityEngine namespace
|
||||
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine");
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
{
|
||||
return type;
|
||||
}
|
||||
|
||||
// 3. Scan all assemblies
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
type = assembly.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
{
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the value of a serialized property
|
||||
/// </summary>
|
||||
private object GetPropertyValue(SerializedProperty prop)
|
||||
{
|
||||
switch (prop.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Integer:
|
||||
return prop.intValue;
|
||||
case SerializedPropertyType.Float:
|
||||
return prop.floatValue;
|
||||
case SerializedPropertyType.Boolean:
|
||||
return prop.boolValue;
|
||||
case SerializedPropertyType.String:
|
||||
return prop.stringValue;
|
||||
case SerializedPropertyType.Vector2:
|
||||
return new { x = prop.vector2Value.x, y = prop.vector2Value.y };
|
||||
case SerializedPropertyType.Vector3:
|
||||
return new { x = prop.vector3Value.x, y = prop.vector3Value.y, z = prop.vector3Value.z };
|
||||
case SerializedPropertyType.Vector4:
|
||||
return new { x = prop.vector4Value.x, y = prop.vector4Value.y, z = prop.vector4Value.z, w = prop.vector4Value.w };
|
||||
case SerializedPropertyType.Rect:
|
||||
return new { x = prop.rectValue.x, y = prop.rectValue.y, width = prop.rectValue.width, height = prop.rectValue.height };
|
||||
case SerializedPropertyType.ArraySize:
|
||||
return prop.arraySize;
|
||||
case SerializedPropertyType.Color:
|
||||
return new { r = prop.colorValue.r, g = prop.colorValue.g, b = prop.colorValue.b, a = prop.colorValue.a };
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
return prop.objectReferenceValue != null ? prop.objectReferenceValue.name : "null";
|
||||
case SerializedPropertyType.Enum:
|
||||
return prop.enumValueIndex >= 0 && prop.enumValueIndex < prop.enumNames.Length
|
||||
? prop.enumNames[prop.enumValueIndex]
|
||||
: prop.enumValueIndex.ToString();
|
||||
case SerializedPropertyType.Vector2Int:
|
||||
return new { x = prop.vector2IntValue.x, y = prop.vector2IntValue.y };
|
||||
case SerializedPropertyType.Vector3Int:
|
||||
return new { x = prop.vector3IntValue.x, y = prop.vector3IntValue.y, z = prop.vector3IntValue.z };
|
||||
case SerializedPropertyType.RectInt:
|
||||
return new { x = prop.rectIntValue.x, y = prop.rectIntValue.y, width = prop.rectIntValue.width, height = prop.rectIntValue.height };
|
||||
case SerializedPropertyType.Bounds:
|
||||
return new {
|
||||
center = new { x = prop.boundsValue.center.x, y = prop.boundsValue.center.y, z = prop.boundsValue.center.z },
|
||||
size = new { x = prop.boundsValue.size.x, y = prop.boundsValue.size.y, z = prop.boundsValue.size.z }
|
||||
};
|
||||
case SerializedPropertyType.BoundsInt:
|
||||
return new {
|
||||
position = new { x = prop.boundsIntValue.position.x, y = prop.boundsIntValue.position.y, z = prop.boundsIntValue.position.z },
|
||||
size = new { x = prop.boundsIntValue.size.x, y = prop.boundsIntValue.size.y, z = prop.boundsIntValue.size.z }
|
||||
};
|
||||
default:
|
||||
return prop.propertyType.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the value of a serialized property
|
||||
/// </summary>
|
||||
private void SetPropertyValue(SerializedProperty prop, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (prop.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Integer:
|
||||
prop.intValue = int.Parse(value);
|
||||
break;
|
||||
case SerializedPropertyType.Float:
|
||||
prop.floatValue = float.Parse(value);
|
||||
break;
|
||||
case SerializedPropertyType.Boolean:
|
||||
prop.boolValue = bool.Parse(value);
|
||||
break;
|
||||
case SerializedPropertyType.String:
|
||||
prop.stringValue = value;
|
||||
break;
|
||||
case SerializedPropertyType.Vector2:
|
||||
ParseVector2(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector3:
|
||||
ParseVector3(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector4:
|
||||
ParseVector4(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Rect:
|
||||
ParseRect(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Color:
|
||||
ParseColor(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Enum:
|
||||
prop.enumValueIndex = System.Array.IndexOf(prop.enumNames, value);
|
||||
if (prop.enumValueIndex < 0)
|
||||
{
|
||||
throw new Exception($"Enum value not found: {value}");
|
||||
}
|
||||
break;
|
||||
case SerializedPropertyType.Vector2Int:
|
||||
ParseVector2Int(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector3Int:
|
||||
ParseVector3Int(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.RectInt:
|
||||
ParseRectInt(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
ParseObjectReference(prop, value);
|
||||
break;
|
||||
default:
|
||||
throw new Exception($"Unsupported property type: {prop.propertyType}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to parse value for {prop.propertyType}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseVector2(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 2) throw new Exception("Vector2 requires 2 values");
|
||||
prop.vector2Value = new Vector2(float.Parse(parts[0]), float.Parse(parts[1]));
|
||||
}
|
||||
|
||||
private void ParseVector3(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 3) throw new Exception("Vector3 requires 3 values");
|
||||
prop.vector3Value = new Vector3(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]));
|
||||
}
|
||||
|
||||
private void ParseVector4(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 4) throw new Exception("Vector4 requires 4 values");
|
||||
prop.vector4Value = new Vector4(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]));
|
||||
}
|
||||
|
||||
private void ParseRect(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 4) throw new Exception("Rect requires 4 values");
|
||||
prop.rectValue = new Rect(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]));
|
||||
}
|
||||
|
||||
private void ParseColor(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length < 3 || parts.Length > 4) throw new Exception("Color requires 3-4 values");
|
||||
float a = parts.Length == 4 ? float.Parse(parts[3]) : 1f;
|
||||
prop.colorValue = new Color(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), a);
|
||||
}
|
||||
|
||||
private void ParseVector2Int(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 2) throw new Exception("Vector2Int requires 2 values");
|
||||
prop.vector2IntValue = new Vector2Int(int.Parse(parts[0]), int.Parse(parts[1]));
|
||||
}
|
||||
|
||||
private void ParseVector3Int(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 3) throw new Exception("Vector3Int requires 3 values");
|
||||
prop.vector3IntValue = new Vector3Int(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]));
|
||||
}
|
||||
|
||||
private void ParseRectInt(SerializedProperty prop, string value)
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length != 4) throw new Exception("RectInt requires 4 values");
|
||||
prop.rectIntValue = new RectInt(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]), int.Parse(parts[3]));
|
||||
}
|
||||
|
||||
private void ParseObjectReference(SerializedProperty prop, string value)
|
||||
{
|
||||
// Handle null/empty values
|
||||
if (string.IsNullOrEmpty(value) || value.ToLower() == "null")
|
||||
{
|
||||
prop.objectReferenceValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for "GameObject:Component" format (e.g., "GameHUD:UIDocument")
|
||||
if (value.Contains(":"))
|
||||
{
|
||||
var parts = value.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var goName = parts[0].Trim();
|
||||
var compTypeName = parts[1].Trim();
|
||||
|
||||
var targetGo = FindGameObject(goName);
|
||||
if (targetGo != null)
|
||||
{
|
||||
var compType = FindComponentType(compTypeName);
|
||||
if (compType != null)
|
||||
{
|
||||
var comp = targetGo.GetComponent(compType);
|
||||
if (comp != null)
|
||||
{
|
||||
prop.objectReferenceValue = comp;
|
||||
return;
|
||||
}
|
||||
throw new Exception($"Component '{compTypeName}' not found on GameObject '{goName}'");
|
||||
}
|
||||
throw new Exception($"Component type not found: '{compTypeName}'");
|
||||
}
|
||||
throw new Exception($"GameObject not found: '{goName}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find GameObject by name
|
||||
var foundGameObject = GameObject.Find(value);
|
||||
if (foundGameObject != null)
|
||||
{
|
||||
prop.objectReferenceValue = foundGameObject;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find in all scene objects
|
||||
var allObjects = Resources.FindObjectsOfTypeAll<GameObject>();
|
||||
foreach (var obj in allObjects)
|
||||
{
|
||||
if (obj.name == value && obj.scene.IsValid())
|
||||
{
|
||||
prop.objectReferenceValue = obj;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load as asset
|
||||
#if UNITY_EDITOR
|
||||
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(value);
|
||||
if (asset != null)
|
||||
{
|
||||
prop.objectReferenceValue = asset;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// If nothing found, throw error with suggestions
|
||||
throw new Exception($"ObjectReference not found: '{value}'. Try GameObject name, 'GameObject:Component' format, or asset path.");
|
||||
}
|
||||
|
||||
#region Parameter Classes
|
||||
|
||||
[Serializable]
|
||||
private class ListParams
|
||||
{
|
||||
public string name;
|
||||
public bool includeDisabled;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class AddParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class RemoveParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SetEnabledParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
public bool enabled;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class GetParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
public string property;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SetParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
public string property;
|
||||
public string value;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class InspectParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class MoveParams
|
||||
{
|
||||
public string name;
|
||||
public string componentType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CopyParams
|
||||
{
|
||||
public string source;
|
||||
public string componentType;
|
||||
public string target;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Classes
|
||||
|
||||
[Serializable]
|
||||
public class ComponentInfo
|
||||
{
|
||||
public string type;
|
||||
public string fullTypeName;
|
||||
public bool enabled;
|
||||
public bool isMonoBehaviour;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ComponentListResult
|
||||
{
|
||||
public int count;
|
||||
public List<ComponentInfo> components;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class PropertyInfo
|
||||
{
|
||||
public string name;
|
||||
public string type;
|
||||
public object value;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetComponentResult
|
||||
{
|
||||
public string componentType;
|
||||
public List<PropertyInfo> properties;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetPropertyResult
|
||||
{
|
||||
public bool success;
|
||||
public string property;
|
||||
public object oldValue;
|
||||
public object newValue;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class InspectComponentResult
|
||||
{
|
||||
public string componentType;
|
||||
public string fullTypeName;
|
||||
public bool enabled;
|
||||
public bool isMonoBehaviour;
|
||||
public List<PropertyInfo> properties;
|
||||
public int propertyCount;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9596e53f4c5aa9141a05dc71f672b3e7
|
||||
321
skills/assets/unity-package/Editor/Handlers/ConsoleHandler.cs
Normal file
321
skills/assets/unity-package/Editor/Handlers/ConsoleHandler.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Console commands
|
||||
/// </summary>
|
||||
public class ConsoleHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Console";
|
||||
|
||||
// Store console logs (Queue로 변경 - O(1) 삽입/삭제)
|
||||
private static Queue<ConsoleLogEntry> logEntries = new Queue<ConsoleLogEntry>(1000);
|
||||
private static readonly object logLock = new object();
|
||||
private static bool isListening = false;
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "GetLogs":
|
||||
return HandleGetLogs(request);
|
||||
case "Clear":
|
||||
return HandleClear(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void StartListening()
|
||||
{
|
||||
if (isListening) return;
|
||||
|
||||
Application.logMessageReceived += OnLogMessageReceived;
|
||||
isListening = true;
|
||||
}
|
||||
|
||||
public static void StopListening()
|
||||
{
|
||||
if (!isListening) return;
|
||||
|
||||
Application.logMessageReceived -= OnLogMessageReceived;
|
||||
isListening = false;
|
||||
}
|
||||
|
||||
private static void OnLogMessageReceived(string message, string stackTrace, LogType type)
|
||||
{
|
||||
lock (logLock)
|
||||
{
|
||||
logEntries.Enqueue(new ConsoleLogEntry
|
||||
{
|
||||
message = message,
|
||||
stackTrace = stackTrace,
|
||||
type = (int)type,
|
||||
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
|
||||
});
|
||||
|
||||
// Keep only last 1000 logs (✅ O(1) 연산으로 최적화)
|
||||
if (logEntries.Count > 1000)
|
||||
{
|
||||
logEntries.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleGetLogs(JsonRpcRequest request)
|
||||
{
|
||||
var param = request.GetParams<GetLogsParams>() ?? new GetLogsParams { count = 50 };
|
||||
var logs = new List<ConsoleLogEntry>();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Get logs from Unity Editor Console using Reflection
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor));
|
||||
if (assembly != null)
|
||||
{
|
||||
var logEntriesType = assembly.GetType("UnityEditor.LogEntries");
|
||||
var logEntryType = assembly.GetType("UnityEditor.LogEntry");
|
||||
|
||||
if (logEntriesType != null && logEntryType != null)
|
||||
{
|
||||
// Get total count
|
||||
var getCountMethod = logEntriesType.GetMethod("GetCount", BindingFlags.Static | BindingFlags.Public);
|
||||
int totalCount = getCountMethod != null ? (int)getCountMethod.Invoke(null, null) : 0;
|
||||
|
||||
// Start from the most recent logs
|
||||
int start = Math.Max(0, totalCount - param.count);
|
||||
|
||||
// Get entries
|
||||
var getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public);
|
||||
|
||||
if (getEntryMethod != null)
|
||||
{
|
||||
for (int i = start; i < totalCount; i++)
|
||||
{
|
||||
var entry = Activator.CreateInstance(logEntryType);
|
||||
var parameters = new object[] { i, entry };
|
||||
getEntryMethod.Invoke(null, parameters);
|
||||
|
||||
// Extract fields
|
||||
var messageField = logEntryType.GetField("message", BindingFlags.Public | BindingFlags.Instance);
|
||||
var conditionField = logEntryType.GetField("condition", BindingFlags.Public | BindingFlags.Instance);
|
||||
var modeField = logEntryType.GetField("mode", BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
string message = conditionField != null ? (string)conditionField.GetValue(entry) : "";
|
||||
string stackTrace = messageField != null ? (string)messageField.GetValue(entry) : "";
|
||||
int mode = modeField != null ? (int)modeField.GetValue(entry) : 0;
|
||||
|
||||
// If message is empty, use first line of stackTrace as message
|
||||
if (string.IsNullOrEmpty(message) && !string.IsNullOrEmpty(stackTrace))
|
||||
{
|
||||
int firstNewLine = stackTrace.IndexOf('\n');
|
||||
if (firstNewLine > 0)
|
||||
{
|
||||
message = stackTrace.Substring(0, firstNewLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
message = stackTrace;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert mode to LogType
|
||||
LogType logType = ConvertModeToLogType(mode);
|
||||
|
||||
// Filter by type
|
||||
if (param.errorsOnly)
|
||||
{
|
||||
if (logType != LogType.Error && logType != LogType.Exception)
|
||||
continue;
|
||||
}
|
||||
else if (!param.includeWarnings)
|
||||
{
|
||||
if (logType == LogType.Warning)
|
||||
continue;
|
||||
}
|
||||
|
||||
logs.Add(new ConsoleLogEntry
|
||||
{
|
||||
message = message,
|
||||
stackTrace = stackTrace,
|
||||
type = (int)logType,
|
||||
timestamp = "" // Editor 로그는 실제 발생 시간을 알 수 없음
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("ConsoleHandler", $"Failed to get Editor console logs: {ex.Message}");
|
||||
|
||||
// Fallback to runtime logs
|
||||
lock (logLock)
|
||||
{
|
||||
var logArray = logEntries.ToArray();
|
||||
int start = Math.Max(0, logArray.Length - param.count);
|
||||
|
||||
for (int i = start; i < logArray.Length; i++)
|
||||
{
|
||||
var log = logArray[i];
|
||||
|
||||
// Filter by type
|
||||
if (param.errorsOnly)
|
||||
{
|
||||
if (log.type != (int)LogType.Error && log.type != (int)LogType.Exception)
|
||||
continue;
|
||||
}
|
||||
else if (!param.includeWarnings)
|
||||
{
|
||||
if (log.type == (int)LogType.Warning)
|
||||
continue;
|
||||
}
|
||||
|
||||
logs.Add(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Runtime: use Application.logMessageReceived logs
|
||||
lock (logLock)
|
||||
{
|
||||
var logArray = logEntries.ToArray();
|
||||
int start = Math.Max(0, logArray.Length - param.count);
|
||||
|
||||
for (int i = start; i < logArray.Length; i++)
|
||||
{
|
||||
var log = logArray[i];
|
||||
|
||||
if (param.errorsOnly)
|
||||
{
|
||||
if (log.type != (int)LogType.Error && log.type != (int)LogType.Exception)
|
||||
continue;
|
||||
}
|
||||
else if (!param.includeWarnings)
|
||||
{
|
||||
if (log.type == (int)LogType.Warning)
|
||||
continue;
|
||||
}
|
||||
|
||||
logs.Add(log);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
private LogType ConvertModeToLogType(int mode)
|
||||
{
|
||||
// Unity LogEntry mode flags
|
||||
// Error = 1 << 0 = 1
|
||||
// Assert = 1 << 1 = 2
|
||||
// Log = 1 << 2 = 4
|
||||
// Fatal = 1 << 4 = 16
|
||||
// DontPreprocessCondition = 1 << 5 = 32
|
||||
// AssetImportError = 1 << 6 = 64
|
||||
// AssetImportWarning = 1 << 7 = 128
|
||||
// ScriptingError = 1 << 8 = 256
|
||||
// ScriptingWarning = 1 << 9 = 512
|
||||
// ScriptingLog = 1 << 10 = 1024
|
||||
// ScriptCompileError = 1 << 11 = 2048
|
||||
// ScriptCompileWarning = 1 << 12 = 4096
|
||||
// StickyError = 1 << 13 = 8192
|
||||
// MayIgnoreLineNumber = 1 << 14 = 16384
|
||||
// ReportBug = 1 << 15 = 32768
|
||||
// DisplayPreviousErrorInStatusBar = 1 << 16 = 65536
|
||||
// ScriptingException = 1 << 17 = 131072
|
||||
// DontExtractStacktrace = 1 << 18 = 262144
|
||||
// ShouldClearOnPlay = 1 << 19 = 524288
|
||||
// GraphCompileError = 1 << 20 = 1048576
|
||||
// ScriptingAssertion = 1 << 21 = 2097152
|
||||
|
||||
// Check error flags
|
||||
if ((mode & (1 | 64 | 256 | 2048 | 1048576)) != 0)
|
||||
return LogType.Error;
|
||||
|
||||
// Check exception flags
|
||||
if ((mode & 131072) != 0)
|
||||
return LogType.Exception;
|
||||
|
||||
// Check warning flags
|
||||
if ((mode & (128 | 512 | 4096)) != 0)
|
||||
return LogType.Warning;
|
||||
|
||||
// Check assert flags
|
||||
if ((mode & (2 | 2097152)) != 0)
|
||||
return LogType.Assert;
|
||||
|
||||
// Default to Log
|
||||
return LogType.Log;
|
||||
}
|
||||
|
||||
private object HandleClear(JsonRpcRequest request)
|
||||
{
|
||||
lock (logLock)
|
||||
{
|
||||
logEntries.Clear();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Also clear Unity Editor console (✅ Reflection null 체크 추가)
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor));
|
||||
if (assembly != null)
|
||||
{
|
||||
var type = assembly.GetType("UnityEditor.LogEntries");
|
||||
if (type != null)
|
||||
{
|
||||
var method = type.GetMethod("Clear");
|
||||
if (method != null)
|
||||
{
|
||||
method.Invoke(null, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("ConsoleHandler", "LogEntries.Clear method not found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("ConsoleHandler", "UnityEditor.LogEntries type not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("ConsoleHandler", $"Failed to clear Editor console: {ex.Message}");
|
||||
}
|
||||
#endif
|
||||
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class GetLogsParams
|
||||
{
|
||||
public int count = 50;
|
||||
public bool errorsOnly = false;
|
||||
public bool includeWarnings = false;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConsoleLogEntry
|
||||
{
|
||||
public string message;
|
||||
public string stackTrace;
|
||||
public int type; // LogType: Error=0, Assert=1, Warning=2, Log=3, Exception=4
|
||||
public string timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34faf809a052bfe4c968da086d1fa1de
|
||||
806
skills/assets/unity-package/Editor/Handlers/DatabaseHandler.cs
Normal file
806
skills/assets/unity-package/Editor/Handlers/DatabaseHandler.cs
Normal file
@@ -0,0 +1,806 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Database command handler
|
||||
/// SQLite 데이터베이스 관리 명령어
|
||||
/// </summary>
|
||||
public class DatabaseHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Database";
|
||||
|
||||
// Reset operation state tracking (for async reset via ResponseQueue)
|
||||
private static bool resetInProgress = false;
|
||||
private static OperationResult resetResult = null;
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Status":
|
||||
return HandleStatus();
|
||||
case "Connect":
|
||||
return HandleConnect(request);
|
||||
case "Disconnect":
|
||||
return HandleDisconnect();
|
||||
case "Reset":
|
||||
return HandleReset(request);
|
||||
case "RunMigrations":
|
||||
return HandleRunMigrations();
|
||||
case "ClearMigrations":
|
||||
return HandleClearMigrations();
|
||||
case "Undo":
|
||||
return HandleUndo();
|
||||
case "Redo":
|
||||
return HandleRedo();
|
||||
case "GetHistory":
|
||||
return HandleGetHistory(request);
|
||||
case "ClearHistory":
|
||||
return HandleClearHistory();
|
||||
case "Query":
|
||||
return HandleQuery(request);
|
||||
default:
|
||||
throw new ArgumentException($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Status
|
||||
private object HandleStatus()
|
||||
{
|
||||
var manager = DatabaseManager.Instance;
|
||||
var health = manager.GetHealthStatus();
|
||||
|
||||
return new DatabaseStatusResult
|
||||
{
|
||||
isInitialized = health.IsInitialized,
|
||||
isConnected = health.IsConnected,
|
||||
isEnabled = health.IsEnabled,
|
||||
databaseFilePath = health.DatabaseFilePath,
|
||||
databaseFileExists = health.DatabaseFileExists,
|
||||
undoCount = manager.CommandHistory?.UndoCount ?? 0,
|
||||
redoCount = manager.CommandHistory?.RedoCount ?? 0
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connect
|
||||
private class ConnectParams
|
||||
{
|
||||
public string databaseFilePath { get; set; }
|
||||
public bool enableWAL { get; set; } = true;
|
||||
}
|
||||
|
||||
private object HandleConnect(JsonRpcRequest request)
|
||||
{
|
||||
if (DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = true,
|
||||
message = "Already connected"
|
||||
};
|
||||
}
|
||||
|
||||
var config = DatabaseConfig.LoadFromEditorPrefs();
|
||||
|
||||
// Override with request params if provided
|
||||
if (request.Params != null)
|
||||
{
|
||||
var paramsObj = request.GetParams<ConnectParams>();
|
||||
if (paramsObj != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(paramsObj.databaseFilePath))
|
||||
{
|
||||
config.DatabaseFilePath = paramsObj.databaseFilePath;
|
||||
}
|
||||
config.EnableWAL = paramsObj.enableWAL;
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous wrapper (blocking call) - Convert UniTask to Task for synchronous execution
|
||||
var result = DatabaseManager.Instance.InitializeAsync(config).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
return new OperationResult
|
||||
{
|
||||
success = result.Success,
|
||||
message = result.Success ? "Connected successfully" : result.ErrorMessage
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Disconnect
|
||||
private object HandleDisconnect()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = true,
|
||||
message = "Not connected"
|
||||
};
|
||||
}
|
||||
|
||||
// Synchronous wrapper - Convert UniTask to Task for synchronous execution
|
||||
DatabaseManager.Instance.ShutdownAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
return new OperationResult
|
||||
{
|
||||
success = true,
|
||||
message = "Disconnected successfully"
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
private object HandleReset(JsonRpcRequest request)
|
||||
{
|
||||
// Check if reset is already in progress
|
||||
if (resetInProgress)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Reset operation already in progress"
|
||||
};
|
||||
}
|
||||
|
||||
// Get send callback from request context
|
||||
var sendCallback = request.GetContext<Action<string>>("sendCallback");
|
||||
if (sendCallback == null)
|
||||
{
|
||||
throw new Exception("Send callback not found in request context");
|
||||
}
|
||||
|
||||
string requestId = request.Id?.ToString() ?? string.Empty;
|
||||
|
||||
// Start async reset operation
|
||||
resetInProgress = true;
|
||||
resetResult = null;
|
||||
|
||||
// Fire and forget async operation
|
||||
ResetDatabaseAsync().Forget();
|
||||
|
||||
// Register delayed response
|
||||
ResponseQueue.Instance.Register(
|
||||
requestId,
|
||||
condition: () => !resetInProgress,
|
||||
resultProvider: () => resetResult ?? new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Reset operation failed unexpectedly"
|
||||
},
|
||||
sendCallback,
|
||||
timeoutSeconds: 120.0 // 2 minutes timeout
|
||||
);
|
||||
|
||||
// Return null to indicate delayed response
|
||||
return null;
|
||||
}
|
||||
|
||||
private async UniTaskVoid ResetDatabaseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = DatabaseConfig.LoadFromEditorPrefs();
|
||||
string dbPath = config.DatabaseFilePath;
|
||||
|
||||
ToolkitLogger.Log("DatabaseHandler", "Starting database reset...");
|
||||
|
||||
// Shutdown first (check IsInitialized, not just IsConnected)
|
||||
if (DatabaseManager.Instance.IsInitialized)
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseHandler", "Shutting down database...");
|
||||
await DatabaseManager.Instance.ShutdownAsync();
|
||||
ToolkitLogger.Log("DatabaseHandler", "Database shutdown complete.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseHandler", $"Shutdown warning: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete database file
|
||||
bool fileDeleted = false;
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
fileDeleted = true;
|
||||
ToolkitLogger.Log("DatabaseHandler", $"Database file deleted: {dbPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
resetResult = new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Failed to delete database file: {ex.Message}"
|
||||
};
|
||||
resetInProgress = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect (will run migrations automatically)
|
||||
ToolkitLogger.Log("DatabaseHandler", "Reconnecting to database...");
|
||||
var result = await DatabaseManager.Instance.InitializeAsync(config);
|
||||
|
||||
resetResult = new OperationResult
|
||||
{
|
||||
success = result.Success,
|
||||
message = result.Success
|
||||
? $"Database reset successfully. File deleted: {fileDeleted}"
|
||||
: $"Reset failed: {result.ErrorMessage}"
|
||||
};
|
||||
|
||||
ToolkitLogger.Log("DatabaseHandler", $"Reset complete: {resetResult.message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseHandler", $"Reset exception: {ex.Message}");
|
||||
resetResult = new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Reset failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
resetInProgress = false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region RunMigrations
|
||||
private object HandleRunMigrations()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database"
|
||||
};
|
||||
}
|
||||
|
||||
var runner = new MigrationRunner(DatabaseManager.Instance);
|
||||
var result = runner.RunMigrationsAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
return new MigrationOperationResult
|
||||
{
|
||||
success = result.Success,
|
||||
message = result.Success
|
||||
? $"Migrations completed: {result.MigrationsApplied} applied"
|
||||
: result.ErrorMessage,
|
||||
migrationsApplied = result.MigrationsApplied
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region ClearMigrations
|
||||
private object HandleClearMigrations()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var connection = DatabaseManager.Instance.Connector.Connection;
|
||||
int deleted = connection.Execute("DELETE FROM migrations");
|
||||
|
||||
return new OperationResult
|
||||
{
|
||||
success = true,
|
||||
message = $"Cleared {deleted} migration record(s)"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Failed to clear migrations: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Undo
|
||||
private object HandleUndo()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database",
|
||||
commandName = "",
|
||||
remainingUndo = 0,
|
||||
remainingRedo = 0
|
||||
};
|
||||
}
|
||||
|
||||
var history = DatabaseManager.Instance.CommandHistory;
|
||||
if (history == null || history.UndoCount == 0)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = "Nothing to undo",
|
||||
commandName = "",
|
||||
remainingUndo = 0,
|
||||
remainingRedo = history?.RedoCount ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string commandName = history.PeekUndo()?.CommandName ?? "Unknown";
|
||||
bool result = history.Undo();
|
||||
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = result,
|
||||
message = result ? "Undo successful" : "Undo failed",
|
||||
commandName = commandName,
|
||||
remainingUndo = history.UndoCount,
|
||||
remainingRedo = history.RedoCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Undo failed: {ex.Message}",
|
||||
commandName = "",
|
||||
remainingUndo = history.UndoCount,
|
||||
remainingRedo = history.RedoCount
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Redo
|
||||
private object HandleRedo()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database",
|
||||
commandName = "",
|
||||
remainingUndo = 0,
|
||||
remainingRedo = 0
|
||||
};
|
||||
}
|
||||
|
||||
var history = DatabaseManager.Instance.CommandHistory;
|
||||
if (history == null || history.RedoCount == 0)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = "Nothing to redo",
|
||||
commandName = "",
|
||||
remainingUndo = history?.UndoCount ?? 0,
|
||||
remainingRedo = 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string commandName = history.PeekRedo()?.CommandName ?? "Unknown";
|
||||
bool result = history.Redo();
|
||||
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = result,
|
||||
message = result ? "Redo successful" : "Redo failed",
|
||||
commandName = commandName,
|
||||
remainingUndo = history.UndoCount,
|
||||
remainingRedo = history.RedoCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UndoRedoResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Redo failed: {ex.Message}",
|
||||
commandName = "",
|
||||
remainingUndo = history.UndoCount,
|
||||
remainingRedo = history.RedoCount
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetHistory
|
||||
private class GetHistoryParams
|
||||
{
|
||||
public int limit { get; set; } = 10;
|
||||
}
|
||||
|
||||
private object HandleGetHistory(JsonRpcRequest request)
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new HistoryResult
|
||||
{
|
||||
undoStack = new HistoryEntryResult[0],
|
||||
redoStack = new HistoryEntryResult[0],
|
||||
totalUndo = 0,
|
||||
totalRedo = 0
|
||||
};
|
||||
}
|
||||
|
||||
var history = DatabaseManager.Instance.CommandHistory;
|
||||
if (history == null)
|
||||
{
|
||||
return new HistoryResult
|
||||
{
|
||||
undoStack = new HistoryEntryResult[0],
|
||||
redoStack = new HistoryEntryResult[0],
|
||||
totalUndo = 0,
|
||||
totalRedo = 0
|
||||
};
|
||||
}
|
||||
|
||||
int limit = 10;
|
||||
if (request.Params != null)
|
||||
{
|
||||
var paramsObj = request.GetParams<GetHistoryParams>();
|
||||
if (paramsObj != null)
|
||||
{
|
||||
limit = paramsObj.limit;
|
||||
}
|
||||
}
|
||||
|
||||
var undoCommands = history.GetUndoStack(limit);
|
||||
var redoCommands = history.GetRedoStack(limit);
|
||||
|
||||
var undoEntries = new HistoryEntryResult[undoCommands.Count];
|
||||
for (int i = 0; i < undoCommands.Count; i++)
|
||||
{
|
||||
var cmd = undoCommands[i];
|
||||
undoEntries[i] = new HistoryEntryResult
|
||||
{
|
||||
name = cmd.CommandName,
|
||||
timestamp = cmd.ExecutedAt.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
canUndo = true
|
||||
};
|
||||
}
|
||||
|
||||
var redoEntries = new HistoryEntryResult[redoCommands.Count];
|
||||
for (int i = 0; i < redoCommands.Count; i++)
|
||||
{
|
||||
var cmd = redoCommands[i];
|
||||
redoEntries[i] = new HistoryEntryResult
|
||||
{
|
||||
name = cmd.CommandName,
|
||||
timestamp = cmd.ExecutedAt.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
canUndo = false
|
||||
};
|
||||
}
|
||||
|
||||
return new HistoryResult
|
||||
{
|
||||
undoStack = undoEntries,
|
||||
redoStack = redoEntries,
|
||||
totalUndo = history.UndoCount,
|
||||
totalRedo = history.RedoCount
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region ClearHistory
|
||||
private object HandleClearHistory()
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database"
|
||||
};
|
||||
}
|
||||
|
||||
var history = DatabaseManager.Instance.CommandHistory;
|
||||
if (history == null)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = "Command history not available"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int undoCount = history.UndoCount;
|
||||
int redoCount = history.RedoCount;
|
||||
history.Clear();
|
||||
|
||||
return new OperationResult
|
||||
{
|
||||
success = true,
|
||||
message = $"Cleared {undoCount} undo and {redoCount} redo entries"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OperationResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Failed to clear history: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Query
|
||||
private class QueryParams
|
||||
{
|
||||
public string table { get; set; }
|
||||
public int limit { get; set; } = 100;
|
||||
}
|
||||
|
||||
// Pre-defined table schemas for safe querying
|
||||
private class MigrationRecord
|
||||
{
|
||||
public int migration_id { get; set; }
|
||||
public string migration_name { get; set; }
|
||||
public string applied_at { get; set; }
|
||||
}
|
||||
|
||||
private class CommandHistoryQueryRecord
|
||||
{
|
||||
public string command_id { get; set; }
|
||||
public string command_name { get; set; }
|
||||
public string command_type { get; set; }
|
||||
public string command_data { get; set; }
|
||||
public string executed_at { get; set; }
|
||||
public string executed_by { get; set; }
|
||||
}
|
||||
|
||||
private class TransformQueryRecord
|
||||
{
|
||||
public int transform_id { get; set; }
|
||||
public int object_id { get; set; }
|
||||
public float position_x { get; set; }
|
||||
public float position_y { get; set; }
|
||||
public float position_z { get; set; }
|
||||
public float rotation_x { get; set; }
|
||||
public float rotation_y { get; set; }
|
||||
public float rotation_z { get; set; }
|
||||
public float rotation_w { get; set; }
|
||||
public float scale_x { get; set; }
|
||||
public float scale_y { get; set; }
|
||||
public float scale_z { get; set; }
|
||||
public string recorded_at { get; set; }
|
||||
}
|
||||
|
||||
private object HandleQuery(JsonRpcRequest request)
|
||||
{
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
return new QueryResult
|
||||
{
|
||||
success = false,
|
||||
message = "Not connected to database",
|
||||
rows = new object[0],
|
||||
columns = new string[0],
|
||||
rowCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Params == null)
|
||||
{
|
||||
return new QueryResult
|
||||
{
|
||||
success = false,
|
||||
message = "Table name is required. Supported: migrations, command_history, transforms",
|
||||
rows = new object[0],
|
||||
columns = new string[0],
|
||||
rowCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var paramsObj = request.GetParams<QueryParams>();
|
||||
if (paramsObj == null || string.IsNullOrEmpty(paramsObj.table))
|
||||
{
|
||||
return new QueryResult
|
||||
{
|
||||
success = false,
|
||||
message = "Table name is required. Supported: migrations, command_history, transforms",
|
||||
rows = new object[0],
|
||||
columns = new string[0],
|
||||
rowCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var connection = DatabaseManager.Instance.Connector.Connection;
|
||||
var results = new System.Collections.Generic.List<System.Collections.Generic.Dictionary<string, object>>();
|
||||
string[] columnNames = null;
|
||||
|
||||
string tableName = paramsObj.table.ToLower().Trim();
|
||||
|
||||
switch (tableName)
|
||||
{
|
||||
case "migrations":
|
||||
{
|
||||
columnNames = new[] { "migration_id", "migration_name", "applied_at" };
|
||||
string sql = $"SELECT migration_id, migration_name, applied_at FROM migrations ORDER BY migration_id DESC LIMIT {paramsObj.limit}";
|
||||
var records = connection.Query<MigrationRecord>(sql);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var row = new System.Collections.Generic.Dictionary<string, object>
|
||||
{
|
||||
["migration_id"] = record.migration_id,
|
||||
["migration_name"] = record.migration_name,
|
||||
["applied_at"] = record.applied_at
|
||||
};
|
||||
results.Add(row);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "command_history":
|
||||
{
|
||||
columnNames = new[] { "command_id", "command_name", "command_type", "command_data", "executed_at", "executed_by" };
|
||||
string sql = $"SELECT command_id, command_name, command_type, command_data, executed_at, executed_by FROM command_history ORDER BY executed_at DESC LIMIT {paramsObj.limit}";
|
||||
var records = connection.Query<CommandHistoryQueryRecord>(sql);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var row = new System.Collections.Generic.Dictionary<string, object>
|
||||
{
|
||||
["command_id"] = record.command_id,
|
||||
["command_name"] = record.command_name,
|
||||
["command_type"] = record.command_type,
|
||||
["command_data"] = record.command_data,
|
||||
["executed_at"] = record.executed_at,
|
||||
["executed_by"] = record.executed_by
|
||||
};
|
||||
results.Add(row);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "transforms":
|
||||
{
|
||||
columnNames = new[] { "transform_id", "object_id", "position_x", "position_y", "position_z", "rotation_x", "rotation_y", "rotation_z", "rotation_w", "scale_x", "scale_y", "scale_z", "recorded_at" };
|
||||
string sql = $"SELECT transform_id, object_id, position_x, position_y, position_z, rotation_x, rotation_y, rotation_z, rotation_w, scale_x, scale_y, scale_z, recorded_at FROM transforms ORDER BY recorded_at DESC LIMIT {paramsObj.limit}";
|
||||
var records = connection.Query<TransformQueryRecord>(sql);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var row = new System.Collections.Generic.Dictionary<string, object>
|
||||
{
|
||||
["transform_id"] = record.transform_id,
|
||||
["object_id"] = record.object_id,
|
||||
["position_x"] = record.position_x,
|
||||
["position_y"] = record.position_y,
|
||||
["position_z"] = record.position_z,
|
||||
["rotation_x"] = record.rotation_x,
|
||||
["rotation_y"] = record.rotation_y,
|
||||
["rotation_z"] = record.rotation_z,
|
||||
["rotation_w"] = record.rotation_w,
|
||||
["scale_x"] = record.scale_x,
|
||||
["scale_y"] = record.scale_y,
|
||||
["scale_z"] = record.scale_z,
|
||||
["recorded_at"] = record.recorded_at
|
||||
};
|
||||
results.Add(row);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return new QueryResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Unknown table: {paramsObj.table}. Supported: migrations, command_history, transforms",
|
||||
rows = new object[0],
|
||||
columns = new string[0],
|
||||
rowCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
return new QueryResult
|
||||
{
|
||||
success = true,
|
||||
message = $"Query executed successfully. {results.Count} row(s) returned.",
|
||||
rows = results.ToArray(),
|
||||
columns = columnNames ?? new string[0],
|
||||
rowCount = results.Count
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new QueryResult
|
||||
{
|
||||
success = false,
|
||||
message = $"Query failed: {ex.Message}",
|
||||
rows = new object[0],
|
||||
columns = new string[0],
|
||||
rowCount = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Response Types
|
||||
public class DatabaseStatusResult
|
||||
{
|
||||
public bool isInitialized { get; set; }
|
||||
public bool isConnected { get; set; }
|
||||
public bool isEnabled { get; set; }
|
||||
public string databaseFilePath { get; set; }
|
||||
public bool databaseFileExists { get; set; }
|
||||
public int undoCount { get; set; }
|
||||
public int redoCount { get; set; }
|
||||
}
|
||||
|
||||
public class OperationResult
|
||||
{
|
||||
public bool success { get; set; }
|
||||
public string message { get; set; }
|
||||
}
|
||||
|
||||
public class MigrationOperationResult : OperationResult
|
||||
{
|
||||
public int migrationsApplied { get; set; }
|
||||
}
|
||||
|
||||
public class UndoRedoResult : OperationResult
|
||||
{
|
||||
public string commandName { get; set; }
|
||||
public int remainingUndo { get; set; }
|
||||
public int remainingRedo { get; set; }
|
||||
}
|
||||
|
||||
public class HistoryEntryResult
|
||||
{
|
||||
public string name { get; set; }
|
||||
public string timestamp { get; set; }
|
||||
public bool canUndo { get; set; }
|
||||
}
|
||||
|
||||
public class HistoryResult
|
||||
{
|
||||
public HistoryEntryResult[] undoStack { get; set; }
|
||||
public HistoryEntryResult[] redoStack { get; set; }
|
||||
public int totalUndo { get; set; }
|
||||
public int totalRedo { get; set; }
|
||||
}
|
||||
|
||||
public class QueryResult : OperationResult
|
||||
{
|
||||
public object[] rows { get; set; }
|
||||
public string[] columns { get; set; }
|
||||
public int rowCount { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38fbb8a2eff8cc9bee6e8536e40d5062
|
||||
427
skills/assets/unity-package/Editor/Handlers/EditorHandler.cs
Normal file
427
skills/assets/unity-package/Editor/Handlers/EditorHandler.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Attributes;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Editor utility commands
|
||||
/// </summary>
|
||||
public class EditorHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Editor";
|
||||
|
||||
private static Dictionary<string, MethodInfo> executableMethods;
|
||||
private static bool isInitialized = false;
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Refresh":
|
||||
return HandleRefresh(request);
|
||||
case "Recompile":
|
||||
return HandleRecompile(request);
|
||||
case "Reimport":
|
||||
return HandleReimport(request);
|
||||
case "GetSelection":
|
||||
return HandleGetSelection(request);
|
||||
case "SetSelection":
|
||||
return HandleSetSelection(request);
|
||||
case "FocusGameView":
|
||||
return HandleFocusGameView(request);
|
||||
case "FocusSceneView":
|
||||
return HandleFocusSceneView(request);
|
||||
case "Execute":
|
||||
return HandleExecute(request);
|
||||
case "ListExecutable":
|
||||
return HandleListExecutable(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleRefresh(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
AssetDatabase.Refresh();
|
||||
return new { success = true, message = "AssetDatabase refreshed" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to refresh AssetDatabase: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Refresh is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleRecompile(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
// Request script compilation
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
|
||||
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
|
||||
return new { success = true, message = "Script recompilation requested" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to request recompilation: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Recompile is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleReimport(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<ReimportParams>(request, "path");
|
||||
|
||||
try
|
||||
{
|
||||
// Build Unity virtual path and physical path
|
||||
string assetPath = $"Assets/{param.path}";
|
||||
string physicalPath = System.IO.Path.Combine(Application.dataPath, param.path);
|
||||
|
||||
// Validate path exists using physical path
|
||||
if (!System.IO.File.Exists(physicalPath) && !System.IO.Directory.Exists(physicalPath))
|
||||
{
|
||||
throw new Exception($"Asset not found: {assetPath}");
|
||||
}
|
||||
|
||||
// Reimport the asset using Unity virtual path
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
return new { success = true, path = assetPath, message = "Asset reimported" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to reimport asset: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Reimport is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleGetSelection(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var activeObject = Selection.activeGameObject;
|
||||
var selectedObjects = Selection.gameObjects;
|
||||
|
||||
if (selectedObjects == null || selectedObjects.Length == 0)
|
||||
{
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
count = 0,
|
||||
activeObject = (object)null,
|
||||
selection = new object[0]
|
||||
};
|
||||
}
|
||||
|
||||
var selectionList = selectedObjects.Select(obj => new
|
||||
{
|
||||
name = obj.name,
|
||||
instanceId = obj.GetInstanceID(),
|
||||
type = obj.GetType().Name
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
count = selectedObjects.Length,
|
||||
activeObject = activeObject != null ? new
|
||||
{
|
||||
name = activeObject.name,
|
||||
instanceId = activeObject.GetInstanceID(),
|
||||
type = activeObject.GetType().Name
|
||||
} : null,
|
||||
selection = selectionList
|
||||
};
|
||||
#else
|
||||
throw new Exception("GetSelection is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetSelection(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<SetSelectionParams>(request, "names");
|
||||
|
||||
try
|
||||
{
|
||||
var selectedObjects = new List<GameObject>();
|
||||
var selectedNames = new List<string>();
|
||||
|
||||
foreach (var nameOrPath in param.names)
|
||||
{
|
||||
GameObject obj = null;
|
||||
|
||||
// Try finding by path first (e.g., "Parent/Child/Target")
|
||||
if (nameOrPath.Contains("/"))
|
||||
{
|
||||
obj = GameObject.Find(nameOrPath);
|
||||
}
|
||||
|
||||
// Try finding by name in all scene objects
|
||||
if (obj == null)
|
||||
{
|
||||
var allObjects = UnityEngine.Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
|
||||
obj = allObjects.FirstOrDefault(go => go.name == nameOrPath);
|
||||
}
|
||||
|
||||
if (obj != null)
|
||||
{
|
||||
selectedObjects.Add(obj);
|
||||
selectedNames.Add(obj.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedObjects.Count > 0)
|
||||
{
|
||||
Selection.objects = selectedObjects.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
Selection.objects = new UnityEngine.Object[0];
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
selectedCount = selectedObjects.Count,
|
||||
selectedNames = selectedNames
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set selection: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("SetSelection is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleFocusGameView(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
EditorApplication.ExecuteMenuItem("Window/General/Game");
|
||||
return new { success = true, message = "Game View focused" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to focus Game View: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("FocusGameView is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleFocusSceneView(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
EditorApplication.ExecuteMenuItem("Window/General/Scene");
|
||||
return new { success = true, message = "Scene View focused" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to focus Scene View: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("FocusSceneView is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private string GetGameObjectPath(GameObject obj)
|
||||
{
|
||||
string path = obj.name;
|
||||
Transform parent = obj.transform.parent;
|
||||
|
||||
while (parent != null)
|
||||
{
|
||||
path = parent.name + "/" + path;
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
#endif
|
||||
|
||||
private void InitializeExecutableMethods()
|
||||
{
|
||||
if (isInitialized)
|
||||
return;
|
||||
|
||||
executableMethods = new Dictionary<string, MethodInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
var types = assembly.GetTypes();
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var attribute = method.GetCustomAttribute<ExecutableMethodAttribute>();
|
||||
if (attribute != null)
|
||||
{
|
||||
if (!method.IsStatic)
|
||||
{
|
||||
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but is not static. Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method.ReturnType != typeof(void))
|
||||
{
|
||||
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but does not return void. Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method.GetParameters().Length > 0)
|
||||
{
|
||||
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but has parameters. Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (executableMethods.ContainsKey(attribute.CommandName))
|
||||
{
|
||||
ToolkitLogger.LogWarning("EditorHandler", $"Duplicate command name '{attribute.CommandName}'. Method {type.FullName}.{method.Name} will override previous registration.");
|
||||
}
|
||||
|
||||
executableMethods[attribute.CommandName] = method;
|
||||
ToolkitLogger.LogDebug("EditorHandler", $"Registered executable method: '{attribute.CommandName}' -> {type.FullName}.{method.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("EditorHandler", $"Failed to scan assembly {assembly.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("EditorHandler", $"Initialized with {executableMethods.Count} executable methods");
|
||||
isInitialized = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("EditorHandler", $"Failed to initialize executable methods: {ex.Message}");
|
||||
executableMethods = new Dictionary<string, MethodInfo>();
|
||||
isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleExecute(JsonRpcRequest request)
|
||||
{
|
||||
InitializeExecutableMethods();
|
||||
|
||||
var param = ValidateParam<ExecuteParams>(request, "commandName");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(param.commandName))
|
||||
{
|
||||
throw new Exception("Command name is required");
|
||||
}
|
||||
|
||||
if (!executableMethods.TryGetValue(param.commandName, out var methodInfo))
|
||||
{
|
||||
throw new Exception($"Unknown command: '{param.commandName}'. Use Editor.ListExecutable to see available commands.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("EditorHandler", $"Executing command: '{param.commandName}'");
|
||||
methodInfo.Invoke(null, null);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
commandName = param.commandName,
|
||||
message = $"Command '{param.commandName}' executed successfully"
|
||||
};
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
var innerException = ex.InnerException ?? ex;
|
||||
ToolkitLogger.LogError("EditorHandler", $"Failed to execute '{param.commandName}': {innerException.Message}\n{innerException.StackTrace}");
|
||||
throw new Exception($"Failed to execute '{param.commandName}': {innerException.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("EditorHandler", $"Failed to execute '{param.commandName}': {ex.Message}\n{ex.StackTrace}");
|
||||
throw new Exception($"Failed to execute '{param.commandName}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleListExecutable(JsonRpcRequest request)
|
||||
{
|
||||
InitializeExecutableMethods();
|
||||
|
||||
var methods = executableMethods.Select(kvp =>
|
||||
{
|
||||
var methodInfo = kvp.Value;
|
||||
var attribute = methodInfo.GetCustomAttribute<ExecutableMethodAttribute>();
|
||||
|
||||
return new
|
||||
{
|
||||
commandName = kvp.Key,
|
||||
description = attribute?.Description ?? "",
|
||||
className = methodInfo.DeclaringType?.FullName ?? "Unknown",
|
||||
methodName = methodInfo.Name
|
||||
};
|
||||
}).OrderBy(m => m.commandName).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
count = methods.Count,
|
||||
methods = methods
|
||||
};
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class ReimportParams
|
||||
{
|
||||
public string path;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetSelectionParams
|
||||
{
|
||||
public string[] names;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ExecuteParams
|
||||
{
|
||||
public string commandName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ed404892bdf87644915acb29004a870
|
||||
469
skills/assets/unity-package/Editor/Handlers/GameObjectHandler.cs
Normal file
469
skills/assets/unity-package/Editor/Handlers/GameObjectHandler.cs
Normal file
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
using UnityEditorToolkit.Editor.Database.Commands;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for GameObject commands
|
||||
/// </summary>
|
||||
public class GameObjectHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "GameObject";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Find":
|
||||
return HandleFind(request);
|
||||
case "Create":
|
||||
return HandleCreate(request);
|
||||
case "Destroy":
|
||||
return HandleDestroy(request);
|
||||
case "SetActive":
|
||||
return HandleSetActive(request);
|
||||
case "SetParent":
|
||||
return HandleSetParent(request);
|
||||
case "GetParent":
|
||||
return HandleGetParent(request);
|
||||
case "GetChildren":
|
||||
return HandleGetChildren(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find GameObject by name or path
|
||||
/// </summary>
|
||||
private object HandleFind(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<FindParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
return new GameObjectInfo
|
||||
{
|
||||
name = obj.name,
|
||||
instanceId = obj.GetInstanceID(),
|
||||
path = GetGameObjectPath(obj),
|
||||
active = obj.activeSelf,
|
||||
tag = obj.tag,
|
||||
layer = obj.layer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new GameObject
|
||||
/// </summary>
|
||||
private object HandleCreate(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<CreateParams>(request, "name");
|
||||
|
||||
// Find parent GameObject if specified
|
||||
GameObject parentObj = null;
|
||||
if (!string.IsNullOrEmpty(param.parent))
|
||||
{
|
||||
parentObj = FindGameObject(param.parent);
|
||||
if (parentObj == null)
|
||||
{
|
||||
throw new Exception($"Parent GameObject not found: {param.parent}");
|
||||
}
|
||||
}
|
||||
|
||||
GameObject obj = new GameObject(param.name);
|
||||
|
||||
// Set parent if specified
|
||||
if (parentObj != null)
|
||||
{
|
||||
obj.transform.SetParent(parentObj.transform);
|
||||
}
|
||||
|
||||
// Register undo
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
|
||||
#endif
|
||||
|
||||
// Record Command for history (without re-executing creation)
|
||||
RecordCreateCommandAsync(obj, parentObj).Forget();
|
||||
|
||||
return new GameObjectInfo
|
||||
{
|
||||
name = obj.name,
|
||||
instanceId = obj.GetInstanceID(),
|
||||
path = GetGameObjectPath(obj),
|
||||
active = obj.activeSelf,
|
||||
tag = obj.tag,
|
||||
layer = obj.layer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record CreateGameObjectCommand for history (without re-executing)
|
||||
/// </summary>
|
||||
private async UniTaskVoid RecordCreateCommandAsync(GameObject obj, GameObject parent)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// Check if database is connected
|
||||
if (DatabaseManager.Instance == null ||
|
||||
!DatabaseManager.Instance.IsConnected ||
|
||||
DatabaseManager.Instance.CommandHistory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create command with already created GameObject reference
|
||||
var command = CreateGameObjectCommand.CreateFromExisting(obj, parent);
|
||||
|
||||
// Add to history without executing (already created)
|
||||
await DatabaseManager.Instance.CommandHistory.RecordCommandAsync(command);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("GameObjectHandler", $"Command recording failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroy GameObject
|
||||
/// </summary>
|
||||
private object HandleDestroy(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<FindParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Execute Command Pattern before actual destruction (database persistence)
|
||||
ExecuteDeleteCommandAsync(obj).Forget();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.Undo.DestroyObjectImmediate(obj);
|
||||
#else
|
||||
GameObject.DestroyImmediate(obj);
|
||||
#endif
|
||||
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute DeleteGameObjectCommand asynchronously (database persistence)
|
||||
/// </summary>
|
||||
private async UniTaskVoid ExecuteDeleteCommandAsync(GameObject obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// Check if database is connected
|
||||
if (DatabaseManager.Instance == null ||
|
||||
!DatabaseManager.Instance.IsConnected ||
|
||||
DatabaseManager.Instance.CommandHistory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create command
|
||||
var command = new DeleteGameObjectCommand(obj);
|
||||
|
||||
// Execute through CommandHistory (async, database persistence)
|
||||
// Note: DeleteGameObjectCommand.CanPersist = false (GameObject reference)
|
||||
// So it will be added to Undo stack but not persisted to database
|
||||
await DatabaseManager.Instance.CommandHistory.ExecuteCommandAsync(command);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("GameObjectHandler", $"Command execution failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set GameObject active state
|
||||
/// </summary>
|
||||
private object HandleSetActive(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetActiveParams>(request, "name and active");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// ✅ RegisterCompleteObjectUndo 사용 (GameObject 전체 상태 기록)
|
||||
UnityEditor.Undo.RegisterCompleteObjectUndo(obj, "Set Active");
|
||||
#endif
|
||||
|
||||
obj.SetActive(param.active);
|
||||
|
||||
return new { success = true, active = obj.activeSelf };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set or remove parent of GameObject
|
||||
/// </summary>
|
||||
private object HandleSetParent(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetParentParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.Undo.SetTransformParent(obj.transform, null, "Set Parent");
|
||||
#endif
|
||||
|
||||
// parent가 null이거나 빈 문자열이면 부모 제거
|
||||
if (string.IsNullOrEmpty(param.parent))
|
||||
{
|
||||
obj.transform.SetParent(null, param.worldPositionStays);
|
||||
return new SetParentResult
|
||||
{
|
||||
success = true,
|
||||
name = obj.name,
|
||||
parent = null,
|
||||
path = GetGameObjectPath(obj)
|
||||
};
|
||||
}
|
||||
|
||||
// 새 부모 찾기
|
||||
var newParent = FindGameObject(param.parent);
|
||||
if (newParent == null)
|
||||
{
|
||||
throw new Exception($"Parent GameObject not found: {param.parent}");
|
||||
}
|
||||
|
||||
// 순환 참조 체크
|
||||
if (IsDescendantOf(newParent.transform, obj.transform))
|
||||
{
|
||||
throw new Exception($"Cannot set parent: {param.parent} is a descendant of {param.name}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.Undo.SetTransformParent(obj.transform, newParent.transform, "Set Parent");
|
||||
#endif
|
||||
|
||||
obj.transform.SetParent(newParent.transform, param.worldPositionStays);
|
||||
|
||||
return new SetParentResult
|
||||
{
|
||||
success = true,
|
||||
name = obj.name,
|
||||
parent = newParent.name,
|
||||
path = GetGameObjectPath(obj)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get parent information of GameObject
|
||||
/// </summary>
|
||||
private object HandleGetParent(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<FindParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
var parent = obj.transform.parent;
|
||||
if (parent == null)
|
||||
{
|
||||
return new ParentInfo
|
||||
{
|
||||
hasParent = false,
|
||||
parent = null
|
||||
};
|
||||
}
|
||||
|
||||
return new ParentInfo
|
||||
{
|
||||
hasParent = true,
|
||||
parent = new GameObjectInfo
|
||||
{
|
||||
name = parent.gameObject.name,
|
||||
instanceId = parent.gameObject.GetInstanceID(),
|
||||
path = GetGameObjectPath(parent.gameObject),
|
||||
active = parent.gameObject.activeSelf,
|
||||
tag = parent.gameObject.tag,
|
||||
layer = parent.gameObject.layer
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get children of GameObject
|
||||
/// </summary>
|
||||
private object HandleGetChildren(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<GetChildrenParams>(request, "name");
|
||||
var obj = FindGameObject(param.name);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
var children = new List<GameObjectInfo>();
|
||||
var transform = obj.transform;
|
||||
|
||||
// recursive 옵션에 따라 직접 자식만 또는 모든 자손 반환
|
||||
if (param.recursive)
|
||||
{
|
||||
GetAllDescendants(transform, children);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < transform.childCount; i++)
|
||||
{
|
||||
var child = transform.GetChild(i).gameObject;
|
||||
children.Add(new GameObjectInfo
|
||||
{
|
||||
name = child.name,
|
||||
instanceId = child.GetInstanceID(),
|
||||
path = GetGameObjectPath(child),
|
||||
active = child.activeSelf,
|
||||
tag = child.tag,
|
||||
layer = child.layer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ChildrenInfo
|
||||
{
|
||||
count = children.Count,
|
||||
children = children
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 재귀적으로 모든 자손 가져오기
|
||||
/// </summary>
|
||||
private void GetAllDescendants(Transform parent, List<GameObjectInfo> list)
|
||||
{
|
||||
for (int i = 0; i < parent.childCount; i++)
|
||||
{
|
||||
var child = parent.GetChild(i).gameObject;
|
||||
list.Add(new GameObjectInfo
|
||||
{
|
||||
name = child.name,
|
||||
instanceId = child.GetInstanceID(),
|
||||
path = GetGameObjectPath(child),
|
||||
active = child.activeSelf,
|
||||
tag = child.tag,
|
||||
layer = child.layer
|
||||
});
|
||||
|
||||
// 재귀 호출
|
||||
GetAllDescendants(child.transform, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// target이 parent의 자손인지 확인 (순환 참조 방지)
|
||||
/// </summary>
|
||||
private bool IsDescendantOf(Transform target, Transform parent)
|
||||
{
|
||||
var current = target;
|
||||
while (current != null)
|
||||
{
|
||||
if (current == parent)
|
||||
return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parameter classes (✅ private으로 변경)
|
||||
[Serializable]
|
||||
private class FindParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CreateParams
|
||||
{
|
||||
public string name;
|
||||
public string parent;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SetActiveParams
|
||||
{
|
||||
public string name;
|
||||
public bool active;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SetParentParams
|
||||
{
|
||||
public string name;
|
||||
public string parent; // null 또는 빈 문자열이면 부모 제거
|
||||
public bool worldPositionStays = true; // 월드 좌표 유지 여부
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class GetChildrenParams
|
||||
{
|
||||
public string name;
|
||||
public bool recursive = false; // true면 모든 자손, false면 직접 자식만
|
||||
}
|
||||
|
||||
// Response classes
|
||||
[Serializable]
|
||||
public class GameObjectInfo
|
||||
{
|
||||
public string name;
|
||||
public int instanceId;
|
||||
public string path;
|
||||
public bool active;
|
||||
public string tag;
|
||||
public int layer;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetParentResult
|
||||
{
|
||||
public bool success;
|
||||
public string name;
|
||||
public string parent;
|
||||
public string path;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ParentInfo
|
||||
{
|
||||
public bool hasParent;
|
||||
public GameObjectInfo parent;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ChildrenInfo
|
||||
{
|
||||
public int count;
|
||||
public List<GameObjectInfo> children;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28de8e44386c3eb4085f117c77e4e4a1
|
||||
103
skills/assets/unity-package/Editor/Handlers/HierarchyHandler.cs
Normal file
103
skills/assets/unity-package/Editor/Handlers/HierarchyHandler.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Hierarchy commands
|
||||
/// </summary>
|
||||
public class HierarchyHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Hierarchy";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Get":
|
||||
return HandleGet(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleGet(JsonRpcRequest request)
|
||||
{
|
||||
var param = request.GetParams<GetParams>() ?? new GetParams();
|
||||
|
||||
var rootObjects = new List<GameObjectInfo>();
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
return rootObjects;
|
||||
}
|
||||
|
||||
foreach (var rootGO in scene.GetRootGameObjects())
|
||||
{
|
||||
// Skip inactive if requested
|
||||
if (!param.includeInactive && !rootGO.activeSelf)
|
||||
continue;
|
||||
|
||||
var info = BuildGameObjectInfo(rootGO, !param.rootOnly, param.includeInactive);
|
||||
rootObjects.Add(info);
|
||||
}
|
||||
|
||||
return rootObjects;
|
||||
}
|
||||
|
||||
private GameObjectInfo BuildGameObjectInfo(GameObject obj, bool includeChildren, bool includeInactive)
|
||||
{
|
||||
var info = new GameObjectInfo
|
||||
{
|
||||
name = obj.name,
|
||||
instanceId = obj.GetInstanceID(),
|
||||
path = GetGameObjectPath(obj),
|
||||
active = obj.activeSelf,
|
||||
tag = obj.tag,
|
||||
layer = obj.layer
|
||||
};
|
||||
|
||||
if (includeChildren && obj.transform.childCount > 0)
|
||||
{
|
||||
info.children = new List<GameObjectInfo>();
|
||||
for (int i = 0; i < obj.transform.childCount; i++)
|
||||
{
|
||||
var child = obj.transform.GetChild(i).gameObject;
|
||||
|
||||
// Skip inactive if requested
|
||||
if (!includeInactive && !child.activeSelf)
|
||||
continue;
|
||||
|
||||
var childInfo = BuildGameObjectInfo(child, true, includeInactive);
|
||||
info.children.Add(childInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class GetParams
|
||||
{
|
||||
public bool rootOnly = false;
|
||||
public bool includeInactive = false;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GameObjectInfo
|
||||
{
|
||||
public string name;
|
||||
public int instanceId;
|
||||
public string path;
|
||||
public bool active;
|
||||
public string tag;
|
||||
public int layer;
|
||||
public List<GameObjectInfo> children;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9ef397f870825f4dbeae7e46bc95981
|
||||
618
skills/assets/unity-package/Editor/Handlers/MaterialHandler.cs
Normal file
618
skills/assets/unity-package/Editor/Handlers/MaterialHandler.cs
Normal file
@@ -0,0 +1,618 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Material commands
|
||||
/// </summary>
|
||||
public class MaterialHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Material";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "GetProperty":
|
||||
return HandleGetProperty(request);
|
||||
case "SetProperty":
|
||||
return HandleSetProperty(request);
|
||||
case "GetColor":
|
||||
return HandleGetColor(request);
|
||||
case "SetColor":
|
||||
return HandleSetColor(request);
|
||||
case "List":
|
||||
return HandleList(request);
|
||||
case "GetShader":
|
||||
return HandleGetShader(request);
|
||||
case "SetShader":
|
||||
return HandleSetShader(request);
|
||||
case "GetTexture":
|
||||
return HandleGetTexture(request);
|
||||
case "SetTexture":
|
||||
return HandleSetTexture(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleGetProperty(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialPropertyParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
if (!material.HasProperty(param.propertyName))
|
||||
{
|
||||
throw new Exception($"Material does not have property: {param.propertyName}");
|
||||
}
|
||||
|
||||
object value = null;
|
||||
string propertyType = GetPropertyType(material, param.propertyName);
|
||||
|
||||
switch (propertyType)
|
||||
{
|
||||
case "Float":
|
||||
case "Range":
|
||||
value = material.GetFloat(param.propertyName);
|
||||
break;
|
||||
case "Int":
|
||||
value = material.GetInt(param.propertyName);
|
||||
break;
|
||||
case "Color":
|
||||
var color = material.GetColor(param.propertyName);
|
||||
value = new { r = color.r, g = color.g, b = color.b, a = color.a };
|
||||
break;
|
||||
case "Vector":
|
||||
var vec = material.GetVector(param.propertyName);
|
||||
value = new { x = vec.x, y = vec.y, z = vec.z, w = vec.w };
|
||||
break;
|
||||
case "Texture":
|
||||
var tex = material.GetTexture(param.propertyName);
|
||||
value = tex != null ? new { name = tex.name, type = tex.GetType().Name } : null;
|
||||
break;
|
||||
default:
|
||||
value = "Unknown type";
|
||||
break;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = param.propertyName,
|
||||
propertyType = propertyType,
|
||||
value = value
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleSetProperty(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialSetPropertyParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
if (!material.HasProperty(param.propertyName))
|
||||
{
|
||||
throw new Exception($"Material does not have property: {param.propertyName}");
|
||||
}
|
||||
|
||||
string propertyType = GetPropertyType(material, param.propertyName);
|
||||
|
||||
switch (propertyType)
|
||||
{
|
||||
case "Float":
|
||||
case "Range":
|
||||
material.SetFloat(param.propertyName, Convert.ToSingle(param.value));
|
||||
break;
|
||||
case "Int":
|
||||
material.SetInt(param.propertyName, Convert.ToInt32(param.value));
|
||||
break;
|
||||
default:
|
||||
throw new Exception($"Property type {propertyType} cannot be set with SetProperty. Use SetColor, SetTexture, or SetVector.");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorUtility.SetDirty(material);
|
||||
#endif
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = param.propertyName,
|
||||
propertyType = propertyType,
|
||||
value = param.value
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleGetColor(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialColorParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
string propName = string.IsNullOrEmpty(param.propertyName) ? "_Color" : param.propertyName;
|
||||
|
||||
if (!material.HasProperty(propName))
|
||||
{
|
||||
throw new Exception($"Material does not have color property: {propName}");
|
||||
}
|
||||
|
||||
var color = material.GetColor(propName);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = propName,
|
||||
color = new
|
||||
{
|
||||
r = color.r,
|
||||
g = color.g,
|
||||
b = color.b,
|
||||
a = color.a,
|
||||
hex = ColorUtility.ToHtmlStringRGBA(color)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleSetColor(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialSetColorParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
string propName = string.IsNullOrEmpty(param.propertyName) ? "_Color" : param.propertyName;
|
||||
|
||||
if (!material.HasProperty(propName))
|
||||
{
|
||||
throw new Exception($"Material does not have color property: {propName}");
|
||||
}
|
||||
|
||||
Color newColor;
|
||||
|
||||
// Parse color from various formats
|
||||
if (!string.IsNullOrEmpty(param.hex))
|
||||
{
|
||||
// Hex format: #RRGGBB or #RRGGBBAA
|
||||
string hexValue = param.hex.StartsWith("#") ? param.hex : "#" + param.hex;
|
||||
if (!ColorUtility.TryParseHtmlString(hexValue, out newColor))
|
||||
{
|
||||
throw new Exception($"Invalid hex color format: {param.hex}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// RGBA format
|
||||
newColor = new Color(
|
||||
param.r ?? 1f,
|
||||
param.g ?? 1f,
|
||||
param.b ?? 1f,
|
||||
param.a ?? 1f
|
||||
);
|
||||
}
|
||||
|
||||
material.SetColor(propName, newColor);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorUtility.SetDirty(material);
|
||||
#endif
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = propName,
|
||||
color = new
|
||||
{
|
||||
r = newColor.r,
|
||||
g = newColor.g,
|
||||
b = newColor.b,
|
||||
a = newColor.a,
|
||||
hex = ColorUtility.ToHtmlStringRGBA(newColor)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleList(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialListParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var materials = param.useShared ? renderer.sharedMaterials : renderer.materials;
|
||||
var materialList = new List<object>();
|
||||
|
||||
for (int i = 0; i < materials.Length; i++)
|
||||
{
|
||||
var mat = materials[i];
|
||||
if (mat != null)
|
||||
{
|
||||
materialList.Add(new
|
||||
{
|
||||
index = i,
|
||||
name = mat.name,
|
||||
shader = mat.shader != null ? mat.shader.name : "None"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
count = materialList.Count,
|
||||
materials = materialList
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleGetShader(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialBaseParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
var shader = material.shader;
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
shader = shader != null ? new
|
||||
{
|
||||
name = shader.name,
|
||||
propertyCount = shader.GetPropertyCount()
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleSetShader(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialSetShaderParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
var shader = Shader.Find(param.shaderName);
|
||||
if (shader == null)
|
||||
{
|
||||
throw new Exception($"Shader not found: {param.shaderName}");
|
||||
}
|
||||
|
||||
material.shader = shader;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorUtility.SetDirty(material);
|
||||
#endif
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
shader = shader.name
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleGetTexture(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<MaterialTextureParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
string propName = string.IsNullOrEmpty(param.propertyName) ? "_MainTex" : param.propertyName;
|
||||
|
||||
if (!material.HasProperty(propName))
|
||||
{
|
||||
throw new Exception($"Material does not have texture property: {propName}");
|
||||
}
|
||||
|
||||
var texture = material.GetTexture(propName);
|
||||
var scale = material.GetTextureScale(propName);
|
||||
var offset = material.GetTextureOffset(propName);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = propName,
|
||||
texture = texture != null ? new
|
||||
{
|
||||
name = texture.name,
|
||||
type = texture.GetType().Name,
|
||||
width = texture.width,
|
||||
height = texture.height
|
||||
} : null,
|
||||
scale = new { x = scale.x, y = scale.y },
|
||||
offset = new { x = offset.x, y = offset.y }
|
||||
};
|
||||
}
|
||||
|
||||
private object HandleSetTexture(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<MaterialSetTextureParams>(request, "gameObject");
|
||||
|
||||
var obj = FindGameObject(param.gameObject);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.gameObject}");
|
||||
}
|
||||
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
throw new Exception($"No Renderer component found on: {param.gameObject}");
|
||||
}
|
||||
|
||||
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
|
||||
|
||||
string propName = string.IsNullOrEmpty(param.propertyName) ? "_MainTex" : param.propertyName;
|
||||
|
||||
if (!material.HasProperty(propName))
|
||||
{
|
||||
throw new Exception($"Material does not have texture property: {propName}");
|
||||
}
|
||||
|
||||
Texture texture = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(param.texturePath))
|
||||
{
|
||||
texture = AssetDatabase.LoadAssetAtPath<Texture>(param.texturePath);
|
||||
if (texture == null)
|
||||
{
|
||||
throw new Exception($"Texture not found at path: {param.texturePath}");
|
||||
}
|
||||
}
|
||||
|
||||
material.SetTexture(propName, texture);
|
||||
|
||||
// Set scale and offset if provided
|
||||
if (param.scaleX.HasValue || param.scaleY.HasValue)
|
||||
{
|
||||
var currentScale = material.GetTextureScale(propName);
|
||||
material.SetTextureScale(propName, new Vector2(
|
||||
param.scaleX ?? currentScale.x,
|
||||
param.scaleY ?? currentScale.y
|
||||
));
|
||||
}
|
||||
|
||||
if (param.offsetX.HasValue || param.offsetY.HasValue)
|
||||
{
|
||||
var currentOffset = material.GetTextureOffset(propName);
|
||||
material.SetTextureOffset(propName, new Vector2(
|
||||
param.offsetX ?? currentOffset.x,
|
||||
param.offsetY ?? currentOffset.y
|
||||
));
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(material);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
gameObject = param.gameObject,
|
||||
material = material.name,
|
||||
propertyName = propName,
|
||||
texture = texture != null ? texture.name : "None"
|
||||
};
|
||||
#else
|
||||
throw new Exception("SetTexture is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private Material GetMaterial(Renderer renderer, int? materialIndex, bool useShared)
|
||||
{
|
||||
var materials = useShared ? renderer.sharedMaterials : renderer.materials;
|
||||
int index = materialIndex ?? 0;
|
||||
|
||||
if (index < 0 || index >= materials.Length)
|
||||
{
|
||||
throw new Exception($"Material index {index} out of range. Available: 0-{materials.Length - 1}");
|
||||
}
|
||||
|
||||
var material = materials[index];
|
||||
if (material == null)
|
||||
{
|
||||
throw new Exception($"Material at index {index} is null");
|
||||
}
|
||||
|
||||
return material;
|
||||
}
|
||||
|
||||
private string GetPropertyType(Material material, string propertyName)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var shader = material.shader;
|
||||
int propCount = shader.GetPropertyCount();
|
||||
|
||||
for (int i = 0; i < propCount; i++)
|
||||
{
|
||||
string name = shader.GetPropertyName(i);
|
||||
if (name == propertyName)
|
||||
{
|
||||
var propType = shader.GetPropertyType(i);
|
||||
return propType.ToString();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class MaterialBaseParams
|
||||
{
|
||||
public string gameObject;
|
||||
public int? materialIndex;
|
||||
public bool useShared;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialPropertyParams : MaterialBaseParams
|
||||
{
|
||||
public string propertyName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialSetPropertyParams : MaterialPropertyParams
|
||||
{
|
||||
public float value;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialColorParams : MaterialBaseParams
|
||||
{
|
||||
public string propertyName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialSetColorParams : MaterialColorParams
|
||||
{
|
||||
public float? r;
|
||||
public float? g;
|
||||
public float? b;
|
||||
public float? a;
|
||||
public string hex;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialListParams
|
||||
{
|
||||
public string gameObject;
|
||||
public bool useShared;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialSetShaderParams : MaterialBaseParams
|
||||
{
|
||||
public string shaderName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialTextureParams : MaterialBaseParams
|
||||
{
|
||||
public string propertyName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MaterialSetTextureParams : MaterialTextureParams
|
||||
{
|
||||
public string texturePath;
|
||||
public float? scaleX;
|
||||
public float? scaleY;
|
||||
public float? offsetX;
|
||||
public float? offsetY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39c746341355d8b479e91019101c6a4e
|
||||
280
skills/assets/unity-package/Editor/Handlers/MenuHandler.cs
Normal file
280
skills/assets/unity-package/Editor/Handlers/MenuHandler.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Unity Editor menu commands
|
||||
/// </summary>
|
||||
public class MenuHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Menu";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Run":
|
||||
return HandleRun(request);
|
||||
case "List":
|
||||
return HandleList(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a menu item by path
|
||||
/// </summary>
|
||||
private object HandleRun(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<RunParams>(request, "menuPath");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(param.menuPath))
|
||||
{
|
||||
throw new Exception("Menu path cannot be empty");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bool success = EditorApplication.ExecuteMenuItem(param.menuPath);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
throw new Exception($"Menu item not found or execution failed: {param.menuPath}");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
menuPath = param.menuPath,
|
||||
message = $"Menu item executed: {param.menuPath}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to execute menu item '{param.menuPath}': {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Menu.Run is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List available menu items
|
||||
/// </summary>
|
||||
private object HandleList(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = request.GetParams<ListParams>() ?? new ListParams();
|
||||
var menus = new List<MenuItemInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
// Get menu items using reflection (Unity internal API)
|
||||
var menuType = typeof(EditorApplication).Assembly.GetType("UnityEditor.Menu");
|
||||
|
||||
if (menuType != null)
|
||||
{
|
||||
// Try to get menu items using internal methods
|
||||
var getMenuItemsMethod = menuType.GetMethod("GetMenuItems",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
if (getMenuItemsMethod != null)
|
||||
{
|
||||
// This method exists but parameters vary by Unity version
|
||||
// Alternative approach: scan common menu paths
|
||||
menus = GetKnownMenuItems();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: return known menu paths
|
||||
menus = GetKnownMenuItems();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
menus = GetKnownMenuItems();
|
||||
}
|
||||
|
||||
// Apply filter if provided
|
||||
if (!string.IsNullOrEmpty(param.filter))
|
||||
{
|
||||
string filterLower = param.filter.ToLower();
|
||||
bool hasWildcard = param.filter.Contains("*");
|
||||
|
||||
if (hasWildcard)
|
||||
{
|
||||
// Simple wildcard matching
|
||||
string pattern = filterLower.Replace("*", "");
|
||||
menus = menus.Where(m =>
|
||||
{
|
||||
string pathLower = m.path.ToLower();
|
||||
if (param.filter.StartsWith("*") && param.filter.EndsWith("*"))
|
||||
return pathLower.Contains(pattern);
|
||||
else if (param.filter.StartsWith("*"))
|
||||
return pathLower.EndsWith(pattern);
|
||||
else if (param.filter.EndsWith("*"))
|
||||
return pathLower.StartsWith(pattern);
|
||||
return pathLower.Contains(pattern);
|
||||
}).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
menus = menus.Where(m =>
|
||||
m.path.ToLower().Contains(filterLower)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
menus = menus.OrderBy(m => m.path).ToList(),
|
||||
count = menus.Count
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to list menu items: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Menu.List is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get known/common menu items
|
||||
/// </summary>
|
||||
private List<MenuItemInfo> GetKnownMenuItems()
|
||||
{
|
||||
var menus = new List<MenuItemInfo>();
|
||||
|
||||
// File menu
|
||||
AddMenuCategory(menus, "File", new[]
|
||||
{
|
||||
"New Scene", "Open Scene", "Save", "Save As...",
|
||||
"New Project...", "Open Project...", "Save Project",
|
||||
"Build Settings...", "Build And Run"
|
||||
});
|
||||
|
||||
// Edit menu
|
||||
AddMenuCategory(menus, "Edit", new[]
|
||||
{
|
||||
"Undo", "Redo", "Cut", "Copy", "Paste", "Duplicate", "Delete",
|
||||
"Select All", "Deselect All", "Select Children", "Select Prefab Root",
|
||||
"Play", "Pause", "Step",
|
||||
"Project Settings...", "Preferences...",
|
||||
"Clear All PlayerPrefs"
|
||||
});
|
||||
|
||||
// Assets menu
|
||||
AddMenuCategory(menus, "Assets", new[]
|
||||
{
|
||||
"Create/Folder", "Create/C# Script", "Create/Shader/Standard Surface Shader",
|
||||
"Create/Material", "Create/Prefab", "Create/Scene",
|
||||
"Open", "Delete", "Rename", "Copy Path",
|
||||
"Import New Asset...", "Import Package/Custom Package...",
|
||||
"Export Package...", "Find References In Scene",
|
||||
"Refresh", "Reimport", "Reimport All"
|
||||
});
|
||||
|
||||
// GameObject menu
|
||||
AddMenuCategory(menus, "GameObject", new[]
|
||||
{
|
||||
"Create Empty", "Create Empty Child",
|
||||
"3D Object/Cube", "3D Object/Sphere", "3D Object/Capsule",
|
||||
"3D Object/Cylinder", "3D Object/Plane", "3D Object/Quad",
|
||||
"2D Object/Sprite", "2D Object/Sprite Mask",
|
||||
"Effects/Particle System", "Effects/Trail", "Effects/Line",
|
||||
"Light/Directional Light", "Light/Point Light", "Light/Spotlight",
|
||||
"Audio/Audio Source", "Audio/Audio Reverb Zone",
|
||||
"Video/Video Player",
|
||||
"UI/Canvas", "UI/Panel", "UI/Button", "UI/Text", "UI/Image",
|
||||
"UI/Raw Image", "UI/Slider", "UI/Scrollbar", "UI/Toggle",
|
||||
"UI/Input Field", "UI/Dropdown", "UI/Scroll View",
|
||||
"Camera", "Move To View", "Align With View", "Align View to Selected"
|
||||
});
|
||||
|
||||
// Component menu
|
||||
AddMenuCategory(menus, "Component", new[]
|
||||
{
|
||||
"Add...",
|
||||
"Mesh/Mesh Filter", "Mesh/Mesh Renderer",
|
||||
"Physics/Rigidbody", "Physics/Box Collider", "Physics/Sphere Collider",
|
||||
"Physics/Capsule Collider", "Physics/Mesh Collider",
|
||||
"Physics 2D/Rigidbody 2D", "Physics 2D/Box Collider 2D",
|
||||
"Rendering/Camera", "Rendering/Light",
|
||||
"Audio/Audio Source", "Audio/Audio Listener",
|
||||
"Scripts"
|
||||
});
|
||||
|
||||
// Window menu
|
||||
AddMenuCategory(menus, "Window", new[]
|
||||
{
|
||||
"General/Scene", "General/Game", "General/Inspector", "General/Hierarchy",
|
||||
"General/Project", "General/Console",
|
||||
"Animation/Animation", "Animation/Animator",
|
||||
"Rendering/Lighting", "Rendering/Light Explorer", "Rendering/Occlusion Culling",
|
||||
"Audio/Audio Mixer",
|
||||
"Package Manager",
|
||||
"2D/Tile Palette", "2D/Sprite Editor",
|
||||
"AI/Navigation",
|
||||
"Asset Store",
|
||||
"Layouts/Default", "Layouts/2 by 3", "Layouts/4 Split", "Layouts/Tall", "Layouts/Wide"
|
||||
});
|
||||
|
||||
// Help menu
|
||||
AddMenuCategory(menus, "Help", new[]
|
||||
{
|
||||
"About Unity...", "Unity Manual", "Scripting Reference",
|
||||
"Unity Forum", "Unity Answers", "Check for Updates"
|
||||
});
|
||||
|
||||
// Tools menu (common items)
|
||||
AddMenuCategory(menus, "Tools", new[]
|
||||
{
|
||||
"Sprite Editor"
|
||||
});
|
||||
|
||||
return menus;
|
||||
}
|
||||
|
||||
private void AddMenuCategory(List<MenuItemInfo> menus, string category, string[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
string path = item.Contains("/") ? $"{category}/{item}" : $"{category}/{item}";
|
||||
menus.Add(new MenuItemInfo { path = path, category = category });
|
||||
}
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class RunParams
|
||||
{
|
||||
public string menuPath;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ListParams
|
||||
{
|
||||
public string filter;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MenuItemInfo
|
||||
{
|
||||
public string path;
|
||||
public string category;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46020fd33042fb949bb1096cc5ad6ec2
|
||||
859
skills/assets/unity-package/Editor/Handlers/PrefabHandler.cs
Normal file
859
skills/assets/unity-package/Editor/Handlers/PrefabHandler.cs
Normal file
@@ -0,0 +1,859 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Prefab commands (instantiate, create, unpack, apply, revert, variant, etc.)
|
||||
/// </summary>
|
||||
public class PrefabHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Prefab";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Instantiate":
|
||||
return HandleInstantiate(request);
|
||||
case "Create":
|
||||
return HandleCreate(request);
|
||||
case "Unpack":
|
||||
return HandleUnpack(request);
|
||||
case "Apply":
|
||||
return HandleApply(request);
|
||||
case "Revert":
|
||||
return HandleRevert(request);
|
||||
case "Variant":
|
||||
return HandleVariant(request);
|
||||
case "GetOverrides":
|
||||
return HandleGetOverrides(request);
|
||||
case "GetSource":
|
||||
return HandleGetSource(request);
|
||||
case "IsInstance":
|
||||
return HandleIsInstance(request);
|
||||
case "Open":
|
||||
return HandleOpen(request);
|
||||
case "Close":
|
||||
return HandleClose(request);
|
||||
case "List":
|
||||
return HandleList(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiate a prefab in the scene
|
||||
/// </summary>
|
||||
private object HandleInstantiate(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<InstantiateParams>(request, "path");
|
||||
|
||||
// Load prefab asset
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.path);
|
||||
if (prefab == null)
|
||||
{
|
||||
throw new Exception($"Prefab not found: {param.path}");
|
||||
}
|
||||
|
||||
// Instantiate prefab
|
||||
GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
|
||||
if (instance == null)
|
||||
{
|
||||
throw new Exception($"Failed to instantiate prefab: {param.path}");
|
||||
}
|
||||
|
||||
// Register undo
|
||||
Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prefab");
|
||||
|
||||
// Set name if provided
|
||||
if (!string.IsNullOrEmpty(param.name))
|
||||
{
|
||||
instance.name = param.name;
|
||||
}
|
||||
|
||||
// Set position if provided
|
||||
if (!string.IsNullOrEmpty(param.position))
|
||||
{
|
||||
var parts = param.position.Split(',');
|
||||
if (parts.Length == 3)
|
||||
{
|
||||
instance.transform.position = new Vector3(
|
||||
float.Parse(parts[0].Trim()),
|
||||
float.Parse(parts[1].Trim()),
|
||||
float.Parse(parts[2].Trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set rotation if provided
|
||||
if (!string.IsNullOrEmpty(param.rotation))
|
||||
{
|
||||
var parts = param.rotation.Split(',');
|
||||
if (parts.Length == 3)
|
||||
{
|
||||
instance.transform.eulerAngles = new Vector3(
|
||||
float.Parse(parts[0].Trim()),
|
||||
float.Parse(parts[1].Trim()),
|
||||
float.Parse(parts[2].Trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set parent if provided
|
||||
if (!string.IsNullOrEmpty(param.parent))
|
||||
{
|
||||
var parentObj = FindGameObject(param.parent);
|
||||
if (parentObj != null)
|
||||
{
|
||||
instance.transform.SetParent(parentObj.transform, true);
|
||||
}
|
||||
}
|
||||
|
||||
return new InstantiateResult
|
||||
{
|
||||
success = true,
|
||||
instanceName = instance.name,
|
||||
prefabPath = param.path,
|
||||
position = new Vector3Info(instance.transform.position)
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.Instantiate is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a prefab from a scene GameObject
|
||||
/// </summary>
|
||||
private object HandleCreate(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<CreateParams>(request, "name and path");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
// Ensure path ends with .prefab
|
||||
string savePath = param.path;
|
||||
if (!savePath.EndsWith(".prefab"))
|
||||
{
|
||||
savePath += ".prefab";
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
string directory = System.IO.Path.GetDirectoryName(savePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory))
|
||||
{
|
||||
CreateFolderRecursively(directory);
|
||||
}
|
||||
|
||||
// Check if prefab already exists
|
||||
var existingPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(savePath);
|
||||
if (existingPrefab != null && !param.overwrite)
|
||||
{
|
||||
throw new Exception($"Prefab already exists: {savePath}. Use --overwrite to replace.");
|
||||
}
|
||||
|
||||
// Create prefab
|
||||
bool success;
|
||||
GameObject prefab;
|
||||
|
||||
if (existingPrefab != null)
|
||||
{
|
||||
prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(obj, savePath, InteractionMode.UserAction, out success);
|
||||
}
|
||||
else
|
||||
{
|
||||
prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(obj, savePath, InteractionMode.UserAction, out success);
|
||||
}
|
||||
|
||||
if (!success || prefab == null)
|
||||
{
|
||||
throw new Exception($"Failed to create prefab at: {savePath}");
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
return new CreateResult
|
||||
{
|
||||
success = true,
|
||||
prefabPath = savePath,
|
||||
sourceName = obj.name,
|
||||
isConnected = PrefabUtility.IsPartOfPrefabInstance(obj)
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.Create is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpack a prefab instance
|
||||
/// </summary>
|
||||
private object HandleUnpack(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<UnpackParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
|
||||
{
|
||||
throw new Exception($"GameObject is not a prefab instance: {param.name}");
|
||||
}
|
||||
|
||||
// Get prefab root
|
||||
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
|
||||
if (prefabRoot == null)
|
||||
{
|
||||
prefabRoot = obj;
|
||||
}
|
||||
|
||||
Undo.RegisterCompleteObjectUndo(prefabRoot, "Unpack Prefab");
|
||||
|
||||
// Unpack based on mode
|
||||
if (param.completely)
|
||||
{
|
||||
PrefabUtility.UnpackPrefabInstance(prefabRoot, PrefabUnpackMode.Completely, InteractionMode.UserAction);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrefabUtility.UnpackPrefabInstance(prefabRoot, PrefabUnpackMode.OutermostRoot, InteractionMode.UserAction);
|
||||
}
|
||||
|
||||
return new { success = true, unpackedObject = prefabRoot.name, completely = param.completely };
|
||||
#else
|
||||
throw new Exception("Prefab.Unpack is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply prefab instance overrides to the source prefab
|
||||
/// </summary>
|
||||
private object HandleApply(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<ApplyParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
|
||||
{
|
||||
throw new Exception($"GameObject is not a prefab instance: {param.name}");
|
||||
}
|
||||
|
||||
// Get prefab root
|
||||
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
|
||||
if (prefabRoot == null)
|
||||
{
|
||||
prefabRoot = obj;
|
||||
}
|
||||
|
||||
// Get source prefab path
|
||||
string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(prefabRoot);
|
||||
|
||||
// Apply all overrides
|
||||
PrefabUtility.ApplyPrefabInstance(prefabRoot, InteractionMode.UserAction);
|
||||
|
||||
return new ApplyResult
|
||||
{
|
||||
success = true,
|
||||
instanceName = prefabRoot.name,
|
||||
prefabPath = prefabPath
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.Apply is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revert prefab instance overrides
|
||||
/// </summary>
|
||||
private object HandleRevert(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<RevertParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
|
||||
{
|
||||
throw new Exception($"GameObject is not a prefab instance: {param.name}");
|
||||
}
|
||||
|
||||
// Get prefab root
|
||||
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
|
||||
if (prefabRoot == null)
|
||||
{
|
||||
prefabRoot = obj;
|
||||
}
|
||||
|
||||
Undo.RegisterCompleteObjectUndo(prefabRoot, "Revert Prefab");
|
||||
|
||||
// Revert all overrides
|
||||
PrefabUtility.RevertPrefabInstance(prefabRoot, InteractionMode.UserAction);
|
||||
|
||||
return new { success = true, revertedObject = prefabRoot.name };
|
||||
#else
|
||||
throw new Exception("Prefab.Revert is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a prefab variant
|
||||
/// </summary>
|
||||
private object HandleVariant(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<VariantParams>(request, "sourcePath and variantPath");
|
||||
|
||||
// Load source prefab
|
||||
var sourcePrefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.sourcePath);
|
||||
if (sourcePrefab == null)
|
||||
{
|
||||
throw new Exception($"Source prefab not found: {param.sourcePath}");
|
||||
}
|
||||
|
||||
// Ensure path ends with .prefab
|
||||
string variantPath = param.variantPath;
|
||||
if (!variantPath.EndsWith(".prefab"))
|
||||
{
|
||||
variantPath += ".prefab";
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
string directory = System.IO.Path.GetDirectoryName(variantPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory))
|
||||
{
|
||||
CreateFolderRecursively(directory);
|
||||
}
|
||||
|
||||
// Instantiate temporarily to create variant
|
||||
var tempInstance = (GameObject)PrefabUtility.InstantiatePrefab(sourcePrefab);
|
||||
|
||||
// Create variant
|
||||
var variant = PrefabUtility.SaveAsPrefabAsset(tempInstance, variantPath);
|
||||
|
||||
// Cleanup temp instance
|
||||
UnityEngine.Object.DestroyImmediate(tempInstance);
|
||||
|
||||
if (variant == null)
|
||||
{
|
||||
throw new Exception($"Failed to create variant at: {variantPath}");
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
return new VariantResult
|
||||
{
|
||||
success = true,
|
||||
sourcePath = param.sourcePath,
|
||||
variantPath = variantPath
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.Variant is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get prefab instance overrides
|
||||
/// </summary>
|
||||
private object HandleGetOverrides(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetOverridesParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
|
||||
{
|
||||
throw new Exception($"GameObject is not a prefab instance: {param.name}");
|
||||
}
|
||||
|
||||
// Get prefab root
|
||||
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
|
||||
if (prefabRoot == null)
|
||||
{
|
||||
prefabRoot = obj;
|
||||
}
|
||||
|
||||
// Get all overrides
|
||||
var objectOverrides = PrefabUtility.GetObjectOverrides(prefabRoot);
|
||||
var addedComponents = PrefabUtility.GetAddedComponents(prefabRoot);
|
||||
var removedComponents = PrefabUtility.GetRemovedComponents(prefabRoot);
|
||||
var addedGameObjects = PrefabUtility.GetAddedGameObjects(prefabRoot);
|
||||
|
||||
var overrideList = new List<OverrideInfo>();
|
||||
|
||||
foreach (var ov in objectOverrides)
|
||||
{
|
||||
overrideList.Add(new OverrideInfo
|
||||
{
|
||||
type = "PropertyOverride",
|
||||
targetName = ov.instanceObject?.name ?? "Unknown",
|
||||
targetType = ov.instanceObject?.GetType().Name ?? "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var ac in addedComponents)
|
||||
{
|
||||
overrideList.Add(new OverrideInfo
|
||||
{
|
||||
type = "AddedComponent",
|
||||
targetName = ac.instanceComponent?.name ?? "Unknown",
|
||||
targetType = ac.instanceComponent?.GetType().Name ?? "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var rc in removedComponents)
|
||||
{
|
||||
overrideList.Add(new OverrideInfo
|
||||
{
|
||||
type = "RemovedComponent",
|
||||
targetName = rc.assetComponent?.name ?? "Unknown",
|
||||
targetType = rc.assetComponent?.GetType().Name ?? "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var ag in addedGameObjects)
|
||||
{
|
||||
overrideList.Add(new OverrideInfo
|
||||
{
|
||||
type = "AddedGameObject",
|
||||
targetName = ag.instanceGameObject?.name ?? "Unknown",
|
||||
targetType = "GameObject"
|
||||
});
|
||||
}
|
||||
|
||||
return new GetOverridesResult
|
||||
{
|
||||
instanceName = prefabRoot.name,
|
||||
hasOverrides = overrideList.Count > 0,
|
||||
overrideCount = overrideList.Count,
|
||||
overrides = overrideList
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.GetOverrides is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get source prefab path of an instance
|
||||
/// </summary>
|
||||
private object HandleGetSource(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetSourceParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
|
||||
{
|
||||
return new GetSourceResult
|
||||
{
|
||||
instanceName = obj.name,
|
||||
isPrefabInstance = false,
|
||||
prefabPath = null,
|
||||
prefabType = "None"
|
||||
};
|
||||
}
|
||||
|
||||
string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(obj);
|
||||
var prefabType = PrefabUtility.GetPrefabAssetType(obj);
|
||||
var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(obj);
|
||||
|
||||
return new GetSourceResult
|
||||
{
|
||||
instanceName = obj.name,
|
||||
isPrefabInstance = true,
|
||||
prefabPath = prefabPath,
|
||||
prefabType = prefabType.ToString(),
|
||||
prefabStatus = prefabStatus.ToString()
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.GetSource is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a GameObject is a prefab instance
|
||||
/// </summary>
|
||||
private object HandleIsInstance(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<IsInstanceParams>(request, "name");
|
||||
|
||||
var obj = FindGameObject(param.name);
|
||||
if (obj == null)
|
||||
{
|
||||
throw new Exception($"GameObject not found: {param.name}");
|
||||
}
|
||||
|
||||
bool isPrefabInstance = PrefabUtility.IsPartOfPrefabInstance(obj);
|
||||
bool isPrefabAsset = PrefabUtility.IsPartOfPrefabAsset(obj);
|
||||
bool isOutermostRoot = PrefabUtility.IsOutermostPrefabInstanceRoot(obj);
|
||||
var prefabType = PrefabUtility.GetPrefabAssetType(obj);
|
||||
|
||||
return new IsInstanceResult
|
||||
{
|
||||
name = obj.name,
|
||||
isPrefabInstance = isPrefabInstance,
|
||||
isPrefabAsset = isPrefabAsset,
|
||||
isOutermostRoot = isOutermostRoot,
|
||||
prefabType = prefabType.ToString()
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.IsInstance is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a prefab in prefab editing mode
|
||||
/// </summary>
|
||||
private object HandleOpen(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<OpenParams>(request, "path");
|
||||
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.path);
|
||||
if (prefab == null)
|
||||
{
|
||||
throw new Exception($"Prefab not found: {param.path}");
|
||||
}
|
||||
|
||||
// Open prefab stage
|
||||
AssetDatabase.OpenAsset(prefab);
|
||||
|
||||
var stage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (stage == null)
|
||||
{
|
||||
throw new Exception($"Failed to open prefab: {param.path}");
|
||||
}
|
||||
|
||||
return new OpenResult
|
||||
{
|
||||
success = true,
|
||||
prefabPath = param.path,
|
||||
prefabName = prefab.name,
|
||||
stageRoot = stage.prefabContentsRoot?.name
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.Open is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close prefab editing mode and return to scene
|
||||
/// </summary>
|
||||
private object HandleClose(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var stage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (stage == null)
|
||||
{
|
||||
return new { success = true, message = "No prefab stage is currently open" };
|
||||
}
|
||||
|
||||
string prefabPath = stage.assetPath;
|
||||
|
||||
// Close prefab stage by returning to main stage
|
||||
StageUtility.GoToMainStage();
|
||||
|
||||
return new { success = true, closedPrefab = prefabPath };
|
||||
#else
|
||||
throw new Exception("Prefab.Close is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all prefabs in a folder
|
||||
/// </summary>
|
||||
private object HandleList(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<ListParams>(request, "");
|
||||
|
||||
string searchPath = string.IsNullOrEmpty(param.path) ? "Assets" : param.path;
|
||||
|
||||
// Find all prefab GUIDs
|
||||
string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { searchPath });
|
||||
|
||||
var prefabs = new List<PrefabInfo>();
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
|
||||
|
||||
if (prefab != null)
|
||||
{
|
||||
var prefabType = PrefabUtility.GetPrefabAssetType(prefab);
|
||||
|
||||
prefabs.Add(new PrefabInfo
|
||||
{
|
||||
name = prefab.name,
|
||||
path = assetPath,
|
||||
type = prefabType.ToString(),
|
||||
isVariant = prefabType == PrefabAssetType.Variant
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ListResult
|
||||
{
|
||||
count = prefabs.Count,
|
||||
searchPath = searchPath,
|
||||
prefabs = prefabs
|
||||
};
|
||||
#else
|
||||
throw new Exception("Prefab.List is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void CreateFolderRecursively(string path)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (AssetDatabase.IsValidFolder(path))
|
||||
return;
|
||||
|
||||
string parent = System.IO.Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent))
|
||||
{
|
||||
CreateFolderRecursively(parent);
|
||||
}
|
||||
|
||||
string folderName = System.IO.Path.GetFileName(path);
|
||||
AssetDatabase.CreateFolder(parent, folderName);
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parameter Classes
|
||||
|
||||
[Serializable]
|
||||
private class InstantiateParams
|
||||
{
|
||||
public string path;
|
||||
public string name;
|
||||
public string position;
|
||||
public string rotation;
|
||||
public string parent;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CreateParams
|
||||
{
|
||||
public string name;
|
||||
public string path;
|
||||
public bool overwrite;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class UnpackParams
|
||||
{
|
||||
public string name;
|
||||
public bool completely;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class ApplyParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class RevertParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class VariantParams
|
||||
{
|
||||
public string sourcePath;
|
||||
public string variantPath;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class GetOverridesParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class GetSourceParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class IsInstanceParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class OpenParams
|
||||
{
|
||||
public string path;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class ListParams
|
||||
{
|
||||
public string path;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Classes
|
||||
|
||||
[Serializable]
|
||||
public class Vector3Info
|
||||
{
|
||||
public float x;
|
||||
public float y;
|
||||
public float z;
|
||||
|
||||
public Vector3Info(Vector3 v)
|
||||
{
|
||||
x = v.x;
|
||||
y = v.y;
|
||||
z = v.z;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class InstantiateResult
|
||||
{
|
||||
public bool success;
|
||||
public string instanceName;
|
||||
public string prefabPath;
|
||||
public Vector3Info position;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class CreateResult
|
||||
{
|
||||
public bool success;
|
||||
public string prefabPath;
|
||||
public string sourceName;
|
||||
public bool isConnected;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ApplyResult
|
||||
{
|
||||
public bool success;
|
||||
public string instanceName;
|
||||
public string prefabPath;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class VariantResult
|
||||
{
|
||||
public bool success;
|
||||
public string sourcePath;
|
||||
public string variantPath;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OverrideInfo
|
||||
{
|
||||
public string type;
|
||||
public string targetName;
|
||||
public string targetType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetOverridesResult
|
||||
{
|
||||
public string instanceName;
|
||||
public bool hasOverrides;
|
||||
public int overrideCount;
|
||||
public List<OverrideInfo> overrides;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetSourceResult
|
||||
{
|
||||
public string instanceName;
|
||||
public bool isPrefabInstance;
|
||||
public string prefabPath;
|
||||
public string prefabType;
|
||||
public string prefabStatus;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class IsInstanceResult
|
||||
{
|
||||
public string name;
|
||||
public bool isPrefabInstance;
|
||||
public bool isPrefabAsset;
|
||||
public bool isOutermostRoot;
|
||||
public string prefabType;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class OpenResult
|
||||
{
|
||||
public bool success;
|
||||
public string prefabPath;
|
||||
public string prefabName;
|
||||
public string stageRoot;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class PrefabInfo
|
||||
{
|
||||
public string name;
|
||||
public string path;
|
||||
public string type;
|
||||
public bool isVariant;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ListResult
|
||||
{
|
||||
public int count;
|
||||
public string searchPath;
|
||||
public List<PrefabInfo> prefabs;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37b6a770044b2b248a23949937838244
|
||||
443
skills/assets/unity-package/Editor/Handlers/PrefsHandler.cs
Normal file
443
skills/assets/unity-package/Editor/Handlers/PrefsHandler.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for EditorPrefs commands
|
||||
/// </summary>
|
||||
public class PrefsHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Prefs";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "GetString":
|
||||
return HandleGetString(request);
|
||||
case "GetInt":
|
||||
return HandleGetInt(request);
|
||||
case "GetFloat":
|
||||
return HandleGetFloat(request);
|
||||
case "GetBool":
|
||||
return HandleGetBool(request);
|
||||
case "SetString":
|
||||
return HandleSetString(request);
|
||||
case "SetInt":
|
||||
return HandleSetInt(request);
|
||||
case "SetFloat":
|
||||
return HandleSetFloat(request);
|
||||
case "SetBool":
|
||||
return HandleSetBool(request);
|
||||
case "DeleteKey":
|
||||
return HandleDeleteKey(request);
|
||||
case "DeleteAll":
|
||||
return HandleDeleteAll(request);
|
||||
case "HasKey":
|
||||
return HandleHasKey(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleGetString(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetPrefsParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
string value = EditorPrefs.GetString(param.key, param.defaultValue ?? "");
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
value = value,
|
||||
type = "string"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to get EditorPrefs string: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleGetInt(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetPrefsParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
int defaultValue = 0;
|
||||
if (param.defaultValue != null)
|
||||
{
|
||||
int.TryParse(param.defaultValue, out defaultValue);
|
||||
}
|
||||
|
||||
int value = EditorPrefs.GetInt(param.key, defaultValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
value = value,
|
||||
type = "int"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to get EditorPrefs int: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleGetFloat(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetPrefsParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
float defaultValue = 0f;
|
||||
if (param.defaultValue != null)
|
||||
{
|
||||
float.TryParse(param.defaultValue, out defaultValue);
|
||||
}
|
||||
|
||||
float value = EditorPrefs.GetFloat(param.key, defaultValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
value = value,
|
||||
type = "float"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to get EditorPrefs float: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleGetBool(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<GetPrefsParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
bool defaultValue = false;
|
||||
if (param.defaultValue != null)
|
||||
{
|
||||
bool.TryParse(param.defaultValue, out defaultValue);
|
||||
}
|
||||
|
||||
bool value = EditorPrefs.GetBool(param.key, defaultValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
value = value,
|
||||
type = "bool"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to get EditorPrefs bool: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetString(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<SetPrefsParams>(request, "params");
|
||||
|
||||
try
|
||||
{
|
||||
EditorPrefs.SetString(param.key, param.value);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = "EditorPrefs string value set"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set EditorPrefs string: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetInt(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<SetPrefsParams>(request, "params");
|
||||
|
||||
try
|
||||
{
|
||||
if (!int.TryParse(param.value, out int intValue))
|
||||
{
|
||||
throw new Exception($"Invalid int value: {param.value}");
|
||||
}
|
||||
|
||||
EditorPrefs.SetInt(param.key, intValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = "EditorPrefs int value set"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set EditorPrefs int: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetFloat(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<SetPrefsParams>(request, "params");
|
||||
|
||||
try
|
||||
{
|
||||
if (!float.TryParse(param.value, out float floatValue))
|
||||
{
|
||||
throw new Exception($"Invalid float value: {param.value}");
|
||||
}
|
||||
|
||||
EditorPrefs.SetFloat(param.key, floatValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = "EditorPrefs float value set"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set EditorPrefs float: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetBool(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<SetPrefsParams>(request, "params");
|
||||
|
||||
try
|
||||
{
|
||||
if (!bool.TryParse(param.value, out bool boolValue))
|
||||
{
|
||||
throw new Exception($"Invalid bool value: {param.value}");
|
||||
}
|
||||
|
||||
EditorPrefs.SetBool(param.key, boolValue);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = "EditorPrefs bool value set"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set EditorPrefs bool: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleDeleteKey(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<DeleteKeyParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
EditorPrefs.DeleteKey(param.key);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
message = "EditorPrefs key deleted"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to delete EditorPrefs key: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleDeleteAll(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
EditorPrefs.DeleteAll();
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "All EditorPrefs deleted"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to delete all EditorPrefs: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleHasKey(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = ValidateParam<HasKeyParams>(request, "key");
|
||||
|
||||
try
|
||||
{
|
||||
bool hasKey = EditorPrefs.HasKey(param.key);
|
||||
|
||||
// 키가 존재하면 값과 타입도 함께 반환
|
||||
if (hasKey)
|
||||
{
|
||||
// 타입 감지: int, float, bool, string 순서로 시도
|
||||
object value = null;
|
||||
string valueType = "unknown";
|
||||
|
||||
// Int 시도
|
||||
try
|
||||
{
|
||||
int intValue = EditorPrefs.GetInt(param.key, int.MinValue);
|
||||
string strValue = EditorPrefs.GetString(param.key, "");
|
||||
|
||||
// Int로 저장된 값이면 문자열 버전이 없거나 숫자 형태
|
||||
if (string.IsNullOrEmpty(strValue) || int.TryParse(strValue, out _))
|
||||
{
|
||||
value = intValue;
|
||||
valueType = "int";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Bool 시도 (int로 저장되므로 0 또는 1인지 확인)
|
||||
if (valueType == "int" && (int)value >= 0 && (int)value <= 1)
|
||||
{
|
||||
bool boolValue = EditorPrefs.GetBool(param.key, false);
|
||||
value = boolValue;
|
||||
valueType = "bool";
|
||||
}
|
||||
|
||||
// Float 시도
|
||||
if (valueType == "unknown")
|
||||
{
|
||||
try
|
||||
{
|
||||
float floatValue = EditorPrefs.GetFloat(param.key, float.MinValue);
|
||||
if (floatValue != float.MinValue)
|
||||
{
|
||||
value = floatValue;
|
||||
valueType = "float";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// String (기본값)
|
||||
if (valueType == "unknown" || valueType == "int")
|
||||
{
|
||||
string strValue = EditorPrefs.GetString(param.key, "");
|
||||
if (!string.IsNullOrEmpty(strValue))
|
||||
{
|
||||
value = strValue;
|
||||
valueType = "string";
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
hasKey = true,
|
||||
type = valueType,
|
||||
value = value
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
key = param.key,
|
||||
hasKey = false
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to check EditorPrefs key: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("EditorPrefs is only available in Unity Editor");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class GetPrefsParams
|
||||
{
|
||||
public string key;
|
||||
public string defaultValue;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetPrefsParams
|
||||
{
|
||||
public string key;
|
||||
public string value;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DeleteKeyParams
|
||||
{
|
||||
public string key;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class HasKeyParams
|
||||
{
|
||||
public string key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: feee58bb2211e13468137282059c065b
|
||||
312
skills/assets/unity-package/Editor/Handlers/SceneHandler.cs
Normal file
312
skills/assets/unity-package/Editor/Handlers/SceneHandler.cs
Normal file
@@ -0,0 +1,312 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor.SceneManagement;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Scene commands
|
||||
/// </summary>
|
||||
public class SceneHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Scene";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "GetCurrent":
|
||||
return HandleGetCurrent(request);
|
||||
case "GetAll":
|
||||
return HandleGetAll(request);
|
||||
case "Load":
|
||||
return HandleLoad(request);
|
||||
case "New":
|
||||
return HandleNew(request);
|
||||
case "Save":
|
||||
return HandleSave(request);
|
||||
case "Unload":
|
||||
return HandleUnload(request);
|
||||
case "SetActive":
|
||||
return HandleSetActive(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
private object HandleGetCurrent(JsonRpcRequest request)
|
||||
{
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
return GetSceneInfo(scene);
|
||||
}
|
||||
|
||||
private object HandleGetAll(JsonRpcRequest request)
|
||||
{
|
||||
var scenes = new List<SceneInfo>();
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
var scene = SceneManager.GetSceneAt(i);
|
||||
scenes.Add(GetSceneInfo(scene));
|
||||
}
|
||||
return scenes;
|
||||
}
|
||||
|
||||
private object HandleLoad(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<LoadParams>(request, "name");
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Editor mode: Use EditorSceneManager for proper undo/redo
|
||||
try
|
||||
{
|
||||
var mode = param.additive ? OpenSceneMode.Additive : OpenSceneMode.Single;
|
||||
EditorSceneManager.OpenScene(param.name, mode);
|
||||
return new { success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to load scene: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
// Runtime mode: Use SceneManager
|
||||
try
|
||||
{
|
||||
var mode = param.additive ? LoadSceneMode.Additive : LoadSceneMode.Single;
|
||||
SceneManager.LoadScene(param.name, mode);
|
||||
return new { success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to load scene: {ex.Message}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleNew(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = request.GetParams<NewSceneParams>() ?? new NewSceneParams();
|
||||
|
||||
try
|
||||
{
|
||||
var setup = param.empty ? NewSceneSetup.EmptyScene : NewSceneSetup.DefaultGameObjects;
|
||||
var mode = param.additive ? NewSceneMode.Additive : NewSceneMode.Single;
|
||||
|
||||
var scene = EditorSceneManager.NewScene(setup, mode);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
scene = GetSceneInfo(scene)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to create new scene: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("New scene is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSave(JsonRpcRequest request)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var param = request.GetParams<SaveSceneParams>() ?? new SaveSceneParams();
|
||||
|
||||
try
|
||||
{
|
||||
Scene scene;
|
||||
|
||||
// 특정 씬 이름이 지정된 경우 해당 씬 찾기
|
||||
if (!string.IsNullOrEmpty(param.sceneName))
|
||||
{
|
||||
scene = SceneManager.GetSceneByName(param.sceneName);
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
scene = SceneManager.GetSceneByPath(param.sceneName);
|
||||
}
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
throw new Exception($"Scene not found: {param.sceneName}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
scene = SceneManager.GetActiveScene();
|
||||
}
|
||||
|
||||
bool saved;
|
||||
|
||||
// 새 경로로 저장 (Save As)
|
||||
if (!string.IsNullOrEmpty(param.path))
|
||||
{
|
||||
saved = EditorSceneManager.SaveScene(scene, param.path);
|
||||
}
|
||||
else
|
||||
{
|
||||
saved = EditorSceneManager.SaveScene(scene);
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = saved,
|
||||
scene = GetSceneInfo(scene)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to save scene: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
throw new Exception("Save scene is only available in Editor mode");
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleUnload(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<UnloadSceneParams>(request, "name");
|
||||
|
||||
#if UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneByName(param.name);
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
scene = SceneManager.GetSceneByPath(param.name);
|
||||
}
|
||||
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
throw new Exception($"Scene not found: {param.name}");
|
||||
}
|
||||
|
||||
// 마지막 씬은 언로드 불가
|
||||
if (SceneManager.sceneCount <= 1)
|
||||
{
|
||||
throw new Exception("Cannot unload the last scene");
|
||||
}
|
||||
|
||||
bool closed = EditorSceneManager.CloseScene(scene, param.removeScene);
|
||||
|
||||
return new { success = closed };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to unload scene: {ex.Message}");
|
||||
}
|
||||
#else
|
||||
try
|
||||
{
|
||||
SceneManager.UnloadSceneAsync(param.name);
|
||||
return new { success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to unload scene: {ex.Message}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private object HandleSetActive(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SetActiveParams>(request, "name");
|
||||
|
||||
try
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneByName(param.name);
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
scene = SceneManager.GetSceneByPath(param.name);
|
||||
}
|
||||
|
||||
if (!scene.IsValid())
|
||||
{
|
||||
throw new Exception($"Scene not found: {param.name}");
|
||||
}
|
||||
|
||||
if (!scene.isLoaded)
|
||||
{
|
||||
throw new Exception($"Scene is not loaded: {param.name}");
|
||||
}
|
||||
|
||||
bool success = SceneManager.SetActiveScene(scene);
|
||||
|
||||
return new
|
||||
{
|
||||
success = success,
|
||||
scene = GetSceneInfo(scene)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to set active scene: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private SceneInfo GetSceneInfo(Scene scene)
|
||||
{
|
||||
return new SceneInfo
|
||||
{
|
||||
name = scene.name,
|
||||
path = scene.path,
|
||||
buildIndex = scene.buildIndex,
|
||||
isLoaded = scene.isLoaded,
|
||||
isDirty = scene.isDirty,
|
||||
rootCount = scene.rootCount
|
||||
};
|
||||
}
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class LoadParams
|
||||
{
|
||||
public string name;
|
||||
public bool additive;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class NewSceneParams
|
||||
{
|
||||
public bool empty; // true: 빈 씬, false: 기본 오브젝트 포함
|
||||
public bool additive; // true: 추가 모드, false: 기존 씬 대체
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SaveSceneParams
|
||||
{
|
||||
public string sceneName; // 저장할 씬 이름 (없으면 활성 씬)
|
||||
public string path; // 저장 경로 (없으면 현재 경로에 저장)
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class UnloadSceneParams
|
||||
{
|
||||
public string name;
|
||||
public bool removeScene; // true: 씬 완전 제거, false: 언로드만
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SetActiveParams
|
||||
{
|
||||
public string name;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SceneInfo
|
||||
{
|
||||
public string name;
|
||||
public string path;
|
||||
public int buildIndex;
|
||||
public bool isLoaded;
|
||||
public bool isDirty;
|
||||
public int rootCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d8087eb6a24b1046a03e8c326cc8245
|
||||
645
skills/assets/unity-package/Editor/Handlers/SnapshotHandler.cs
Normal file
645
skills/assets/unity-package/Editor/Handlers/SnapshotHandler.cs
Normal file
@@ -0,0 +1,645 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Protocol;
|
||||
using UnityEditorToolkit.Editor.Database;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
#endif
|
||||
|
||||
namespace UnityEditorToolkit.Handlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handler for Snapshot commands (Save/Load scene state to/from database)
|
||||
/// </summary>
|
||||
public class SnapshotHandler : BaseHandler
|
||||
{
|
||||
public override string Category => "Snapshot";
|
||||
|
||||
protected override object HandleMethod(string method, JsonRpcRequest request)
|
||||
{
|
||||
switch (method)
|
||||
{
|
||||
case "Save":
|
||||
return HandleSave(request);
|
||||
case "List":
|
||||
return HandleList(request);
|
||||
case "Restore":
|
||||
return HandleRestore(request);
|
||||
case "Delete":
|
||||
return HandleDelete(request);
|
||||
case "Get":
|
||||
return HandleGet(request);
|
||||
default:
|
||||
throw new Exception($"Unknown method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save current scene state as a snapshot
|
||||
/// </summary>
|
||||
private object HandleSave(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<SaveParams>(request, "name");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
|
||||
// Get or create scene record
|
||||
int sceneId = EnsureSceneRecord(connection, scene);
|
||||
|
||||
// Serialize scene state
|
||||
var snapshotData = SerializeSceneState(scene);
|
||||
var snapshotJson = JsonUtility.ToJson(snapshotData);
|
||||
|
||||
// Insert snapshot
|
||||
var sql = @"
|
||||
INSERT INTO snapshots (scene_id, snapshot_name, snapshot_data, description, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
|
||||
";
|
||||
connection.Execute(sql, sceneId, param.name, snapshotJson, param.description ?? "");
|
||||
|
||||
// Get the inserted snapshot ID
|
||||
var lastIdSql = "SELECT last_insert_rowid()";
|
||||
var snapshotId = connection.ExecuteScalar<int>(lastIdSql);
|
||||
|
||||
return new SnapshotResult
|
||||
{
|
||||
success = true,
|
||||
snapshotId = snapshotId,
|
||||
snapshotName = param.name,
|
||||
sceneName = scene.name,
|
||||
scenePath = scene.path,
|
||||
objectCount = snapshotData.objects.Count,
|
||||
message = $"Snapshot '{param.name}' saved with {snapshotData.objects.Count} objects"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all snapshots for current scene or all scenes
|
||||
/// </summary>
|
||||
private object HandleList(JsonRpcRequest request)
|
||||
{
|
||||
var param = request.GetParams<ListParams>() ?? new ListParams();
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
string sql;
|
||||
List<SnapshotRecord> records;
|
||||
|
||||
if (param.allScenes)
|
||||
{
|
||||
sql = @"
|
||||
SELECT s.snapshot_id, s.scene_id, sc.scene_name, sc.scene_path,
|
||||
s.snapshot_name, s.description, s.created_at
|
||||
FROM snapshots s
|
||||
INNER JOIN scenes sc ON s.scene_id = sc.scene_id
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ?
|
||||
";
|
||||
records = connection.Query<SnapshotRecord>(sql, param.limit);
|
||||
}
|
||||
else
|
||||
{
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
sql = @"
|
||||
SELECT s.snapshot_id, s.scene_id, sc.scene_name, sc.scene_path,
|
||||
s.snapshot_name, s.description, s.created_at
|
||||
FROM snapshots s
|
||||
INNER JOIN scenes sc ON s.scene_id = sc.scene_id
|
||||
WHERE sc.scene_path = ?
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ?
|
||||
";
|
||||
records = connection.Query<SnapshotRecord>(sql, scene.path, param.limit);
|
||||
}
|
||||
|
||||
var snapshots = records.Select(r => new SnapshotInfo
|
||||
{
|
||||
snapshotId = r.snapshot_id,
|
||||
sceneId = r.scene_id,
|
||||
sceneName = r.scene_name,
|
||||
scenePath = r.scene_path,
|
||||
snapshotName = r.snapshot_name,
|
||||
description = r.description,
|
||||
createdAt = r.created_at
|
||||
}).ToList();
|
||||
|
||||
return new ListResult
|
||||
{
|
||||
success = true,
|
||||
count = snapshots.Count,
|
||||
snapshots = snapshots
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get snapshot details by ID
|
||||
/// </summary>
|
||||
private object HandleGet(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<GetParams>(request, "snapshotId");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
var sql = @"
|
||||
SELECT s.snapshot_id, s.scene_id, sc.scene_name, sc.scene_path,
|
||||
s.snapshot_name, s.snapshot_data, s.description, s.created_at
|
||||
FROM snapshots s
|
||||
INNER JOIN scenes sc ON s.scene_id = sc.scene_id
|
||||
WHERE s.snapshot_id = ?
|
||||
";
|
||||
var records = connection.Query<SnapshotDataRecord>(sql, param.snapshotId);
|
||||
|
||||
if (records.Count() == 0)
|
||||
{
|
||||
throw new Exception($"Snapshot with ID {param.snapshotId} not found");
|
||||
}
|
||||
|
||||
var record = records[0];
|
||||
var snapshotData = JsonUtility.FromJson<SceneSnapshotData>(record.snapshot_data);
|
||||
|
||||
return new GetResult
|
||||
{
|
||||
success = true,
|
||||
snapshotId = record.snapshot_id,
|
||||
sceneId = record.scene_id,
|
||||
sceneName = record.scene_name,
|
||||
scenePath = record.scene_path,
|
||||
snapshotName = record.snapshot_name,
|
||||
description = record.description,
|
||||
createdAt = record.created_at,
|
||||
objectCount = snapshotData.objects.Count,
|
||||
data = snapshotData
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore scene from snapshot
|
||||
/// </summary>
|
||||
private object HandleRestore(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<RestoreParams>(request, "snapshotId");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
// Get snapshot data
|
||||
var sql = @"
|
||||
SELECT s.snapshot_id, s.scene_id, sc.scene_name, sc.scene_path,
|
||||
s.snapshot_name, s.snapshot_data, s.description, s.created_at
|
||||
FROM snapshots s
|
||||
INNER JOIN scenes sc ON s.scene_id = sc.scene_id
|
||||
WHERE s.snapshot_id = ?
|
||||
";
|
||||
var records = connection.Query<SnapshotDataRecord>(sql, param.snapshotId);
|
||||
|
||||
if (records.Count() == 0)
|
||||
{
|
||||
throw new Exception($"Snapshot with ID {param.snapshotId} not found");
|
||||
}
|
||||
|
||||
var record = records[0];
|
||||
var snapshotData = JsonUtility.FromJson<SceneSnapshotData>(record.snapshot_data);
|
||||
|
||||
// Restore scene state
|
||||
int restoredCount = RestoreSceneState(snapshotData, param.clearScene);
|
||||
|
||||
return new RestoreResult
|
||||
{
|
||||
success = true,
|
||||
snapshotId = record.snapshot_id,
|
||||
snapshotName = record.snapshot_name,
|
||||
sceneName = record.scene_name,
|
||||
restoredObjects = restoredCount,
|
||||
message = $"Restored {restoredCount} objects from snapshot '{record.snapshot_name}'"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a snapshot
|
||||
/// </summary>
|
||||
private object HandleDelete(JsonRpcRequest request)
|
||||
{
|
||||
var param = ValidateParam<DeleteParams>(request, "snapshotId");
|
||||
|
||||
if (!DatabaseManager.Instance.IsConnected)
|
||||
{
|
||||
throw new Exception("Database is not connected");
|
||||
}
|
||||
|
||||
var connection = DatabaseManager.Instance.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception("Failed to get database connection");
|
||||
}
|
||||
|
||||
// Check if snapshot exists
|
||||
var checkSql = "SELECT snapshot_name FROM snapshots WHERE snapshot_id = ?";
|
||||
var names = connection.Query<NameRecord>(checkSql, param.snapshotId);
|
||||
|
||||
if (names.Count() == 0)
|
||||
{
|
||||
throw new Exception($"Snapshot with ID {param.snapshotId} not found");
|
||||
}
|
||||
|
||||
var snapshotName = names[0].snapshot_name;
|
||||
|
||||
// Delete snapshot
|
||||
var deleteSql = "DELETE FROM snapshots WHERE snapshot_id = ?";
|
||||
connection.Execute(deleteSql, param.snapshotId);
|
||||
|
||||
return new DeleteResult
|
||||
{
|
||||
success = true,
|
||||
snapshotId = param.snapshotId,
|
||||
snapshotName = snapshotName,
|
||||
message = $"Snapshot '{snapshotName}' deleted"
|
||||
};
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private int EnsureSceneRecord(SQLite.SQLiteConnection connection, Scene scene)
|
||||
{
|
||||
var checkSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var ids = connection.Query<SceneIdRecord>(checkSql, scene.path);
|
||||
|
||||
if (ids.Count() > 0)
|
||||
{
|
||||
return ids[0].scene_id;
|
||||
}
|
||||
|
||||
// Insert new scene record
|
||||
var insertSql = @"
|
||||
INSERT INTO scenes (scene_name, scene_path, build_index, is_loaded, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
";
|
||||
connection.Execute(insertSql, scene.name, scene.path, scene.buildIndex);
|
||||
|
||||
var lastIdSql = "SELECT last_insert_rowid()";
|
||||
return connection.ExecuteScalar<int>(lastIdSql);
|
||||
}
|
||||
|
||||
private SceneSnapshotData SerializeSceneState(Scene scene)
|
||||
{
|
||||
var data = new SceneSnapshotData
|
||||
{
|
||||
sceneName = scene.name,
|
||||
scenePath = scene.path,
|
||||
buildIndex = scene.buildIndex,
|
||||
capturedAt = DateTime.Now.ToString("O"),
|
||||
objects = new List<GameObjectData>()
|
||||
};
|
||||
|
||||
// Get all root objects
|
||||
var rootObjects = scene.GetRootGameObjects();
|
||||
foreach (var root in rootObjects)
|
||||
{
|
||||
SerializeGameObjectRecursive(root, data.objects);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private void SerializeGameObjectRecursive(GameObject obj, List<GameObjectData> list)
|
||||
{
|
||||
var objData = new GameObjectData
|
||||
{
|
||||
instanceId = obj.GetInstanceID(),
|
||||
name = obj.name,
|
||||
tag = obj.tag,
|
||||
layer = obj.layer,
|
||||
isActive = obj.activeSelf,
|
||||
isStatic = obj.isStatic,
|
||||
transform = new TransformData
|
||||
{
|
||||
positionX = obj.transform.localPosition.x,
|
||||
positionY = obj.transform.localPosition.y,
|
||||
positionZ = obj.transform.localPosition.z,
|
||||
rotationX = obj.transform.localRotation.x,
|
||||
rotationY = obj.transform.localRotation.y,
|
||||
rotationZ = obj.transform.localRotation.z,
|
||||
rotationW = obj.transform.localRotation.w,
|
||||
scaleX = obj.transform.localScale.x,
|
||||
scaleY = obj.transform.localScale.y,
|
||||
scaleZ = obj.transform.localScale.z
|
||||
},
|
||||
children = new List<GameObjectData>()
|
||||
};
|
||||
|
||||
list.Add(objData);
|
||||
|
||||
// Serialize children
|
||||
for (int i = 0; i < obj.transform.childCount; i++)
|
||||
{
|
||||
var child = obj.transform.GetChild(i).gameObject;
|
||||
SerializeGameObjectRecursive(child, objData.children);
|
||||
}
|
||||
}
|
||||
|
||||
private int RestoreSceneState(SceneSnapshotData data, bool clearScene)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (clearScene)
|
||||
{
|
||||
// Clear current scene objects (except preserved ones)
|
||||
var currentRoots = SceneManager.GetActiveScene().GetRootGameObjects();
|
||||
foreach (var obj in currentRoots)
|
||||
{
|
||||
if (!obj.name.StartsWith("_") && obj.tag != "EditorOnly")
|
||||
{
|
||||
Undo.DestroyObjectImmediate(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
int restoredCount = 0;
|
||||
|
||||
// Restore objects
|
||||
foreach (var objData in data.objects)
|
||||
{
|
||||
restoredCount += RestoreGameObjectRecursive(objData, null);
|
||||
}
|
||||
|
||||
return restoredCount;
|
||||
}
|
||||
|
||||
private int RestoreGameObjectRecursive(GameObjectData data, Transform parent)
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
// Try to find existing object by instance ID or name
|
||||
GameObject obj = null;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
obj = EditorUtility.InstanceIDToObject(data.instanceId) as GameObject;
|
||||
#endif
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
// Create new object
|
||||
obj = new GameObject(data.name);
|
||||
#if UNITY_EDITOR
|
||||
Undo.RegisterCreatedObjectUndo(obj, "Restore Snapshot");
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
Undo.RecordObject(obj.transform, "Restore Snapshot");
|
||||
Undo.RecordObject(obj, "Restore Snapshot");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Restore properties
|
||||
obj.name = data.name;
|
||||
obj.tag = data.tag;
|
||||
obj.layer = data.layer;
|
||||
obj.SetActive(data.isActive);
|
||||
obj.isStatic = data.isStatic;
|
||||
|
||||
if (parent != null)
|
||||
{
|
||||
obj.transform.SetParent(parent, false);
|
||||
}
|
||||
|
||||
// Restore transform
|
||||
obj.transform.localPosition = new Vector3(data.transform.positionX, data.transform.positionY, data.transform.positionZ);
|
||||
obj.transform.localRotation = new Quaternion(data.transform.rotationX, data.transform.rotationY, data.transform.rotationZ, data.transform.rotationW);
|
||||
obj.transform.localScale = new Vector3(data.transform.scaleX, data.transform.scaleY, data.transform.scaleZ);
|
||||
|
||||
count++;
|
||||
|
||||
// Restore children
|
||||
foreach (var childData in data.children)
|
||||
{
|
||||
count += RestoreGameObjectRecursive(childData, obj.transform);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Classes
|
||||
|
||||
// Parameter classes
|
||||
[Serializable]
|
||||
public class SaveParams
|
||||
{
|
||||
public string name;
|
||||
public string description;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ListParams
|
||||
{
|
||||
public bool allScenes = false;
|
||||
public int limit = 50;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetParams
|
||||
{
|
||||
public int snapshotId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class RestoreParams
|
||||
{
|
||||
public int snapshotId;
|
||||
public bool clearScene = false;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DeleteParams
|
||||
{
|
||||
public int snapshotId;
|
||||
}
|
||||
|
||||
// Database record classes
|
||||
private class SnapshotRecord
|
||||
{
|
||||
public int snapshot_id { get; set; }
|
||||
public int scene_id { get; set; }
|
||||
public string scene_name { get; set; }
|
||||
public string scene_path { get; set; }
|
||||
public string snapshot_name { get; set; }
|
||||
public string description { get; set; }
|
||||
public string created_at { get; set; }
|
||||
}
|
||||
|
||||
private class SnapshotDataRecord
|
||||
{
|
||||
public int snapshot_id { get; set; }
|
||||
public int scene_id { get; set; }
|
||||
public string scene_name { get; set; }
|
||||
public string scene_path { get; set; }
|
||||
public string snapshot_name { get; set; }
|
||||
public string snapshot_data { get; set; }
|
||||
public string description { get; set; }
|
||||
public string created_at { get; set; }
|
||||
}
|
||||
|
||||
private class SceneIdRecord
|
||||
{
|
||||
public int scene_id { get; set; }
|
||||
}
|
||||
|
||||
private class NameRecord
|
||||
{
|
||||
public string snapshot_name { get; set; }
|
||||
}
|
||||
|
||||
// Result classes
|
||||
[Serializable]
|
||||
public class SnapshotResult
|
||||
{
|
||||
public bool success;
|
||||
public int snapshotId;
|
||||
public string snapshotName;
|
||||
public string sceneName;
|
||||
public string scenePath;
|
||||
public int objectCount;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ListResult
|
||||
{
|
||||
public bool success;
|
||||
public int count;
|
||||
public List<SnapshotInfo> snapshots;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SnapshotInfo
|
||||
{
|
||||
public int snapshotId;
|
||||
public int sceneId;
|
||||
public string sceneName;
|
||||
public string scenePath;
|
||||
public string snapshotName;
|
||||
public string description;
|
||||
public string createdAt;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GetResult
|
||||
{
|
||||
public bool success;
|
||||
public int snapshotId;
|
||||
public int sceneId;
|
||||
public string sceneName;
|
||||
public string scenePath;
|
||||
public string snapshotName;
|
||||
public string description;
|
||||
public string createdAt;
|
||||
public int objectCount;
|
||||
public SceneSnapshotData data;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class RestoreResult
|
||||
{
|
||||
public bool success;
|
||||
public int snapshotId;
|
||||
public string snapshotName;
|
||||
public string sceneName;
|
||||
public int restoredObjects;
|
||||
public string message;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DeleteResult
|
||||
{
|
||||
public bool success;
|
||||
public int snapshotId;
|
||||
public string snapshotName;
|
||||
public string message;
|
||||
}
|
||||
|
||||
// Snapshot data classes
|
||||
[Serializable]
|
||||
public class SceneSnapshotData
|
||||
{
|
||||
public string sceneName;
|
||||
public string scenePath;
|
||||
public int buildIndex;
|
||||
public string capturedAt;
|
||||
public List<GameObjectData> objects;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class GameObjectData
|
||||
{
|
||||
public int instanceId;
|
||||
public string name;
|
||||
public string tag;
|
||||
public int layer;
|
||||
public bool isActive;
|
||||
public bool isStatic;
|
||||
public TransformData transform;
|
||||
public List<GameObjectData> children;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TransformData
|
||||
{
|
||||
public float positionX;
|
||||
public float positionY;
|
||||
public float positionZ;
|
||||
public float rotationX;
|
||||
public float rotationY;
|
||||
public float rotationZ;
|
||||
public float rotationW;
|
||||
public float scaleX;
|
||||
public float scaleY;
|
||||
public float scaleZ;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user