- 모든 컨트롤러 에디터를 IMGUI → UI Toolkit(UXML/USS)으로 전환 (Camera, Item, Event, Avatar, System, StreamDeck, OptiTrack, Facial) - StreamingleCommon.uss 공통 테마 + 개별 에디터 USS 스타일시트 - SystemController 서브매니저 분리 (OptiTrack, Facial, Recording, Screenshot 등) - 런타임 컨트롤 패널 (ESC 토글, 좌측 오버레이, 150% 스케일) - 웹 대시보드 서버 (StreamingleDashboardServer) + 리타게팅 통합 - 설정 도구(StreamingleControllerSetupTool) UXML 재작성 + 원클릭 설정 - SimplePoseTransfer UXML 에디터 추가 - 전체 UXML 한글화 + NanumGothic 폰트 적용 - Streamingle.Debug → Streamingle.Debugging 네임스페이스 변경 (Debug.Log 충돌 해결) - 불필요 코드 제거 (rawkey.cs, RetargetingHTTPServer, OptitrackSkeletonAnimator 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
584 lines
19 KiB
C#
584 lines
19 KiB
C#
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
/// <summary>
|
|
/// Runtime Control Panel - Game View에서 ESC로 열고 닫는 컨트롤 패널.
|
|
/// SystemController의 서브매니저로 동작하며, UIDocument와 PanelSettings를 자동 생성.
|
|
/// </summary>
|
|
[Serializable]
|
|
public class RuntimeControlPanelManager
|
|
{
|
|
[Tooltip("런타임 컨트롤 패널 활성화")]
|
|
public bool panelEnabled = true;
|
|
|
|
private UIDocument uiDocument;
|
|
private PanelSettings panelSettings;
|
|
private VisualElement root;
|
|
private VisualElement panelRoot;
|
|
private bool isVisible;
|
|
private bool isInitialized;
|
|
|
|
private Label catTitle;
|
|
private Label catDesc;
|
|
private ScrollView actionList;
|
|
private string currentCategory;
|
|
|
|
private VisualElement wsDot;
|
|
private Label wsValue;
|
|
private VisualElement recDot;
|
|
private VisualElement optitrackDot;
|
|
private Label facialValue;
|
|
private VisualElement retargetingDot;
|
|
private float lastStatusUpdate;
|
|
private const float STATUS_INTERVAL = 0.5f;
|
|
|
|
private Dictionary<string, Button> catButtons;
|
|
private StreamDeckServerManager manager;
|
|
|
|
private Action<string> log;
|
|
private Action<string> logError;
|
|
|
|
private bool initStarted;
|
|
|
|
public void Initialize(Transform parent, Action<string> log, Action<string> logError)
|
|
{
|
|
this.log = log;
|
|
this.logError = logError;
|
|
|
|
if (!panelEnabled) return;
|
|
|
|
var visualTree = Resources.Load<VisualTreeAsset>("StreamingleUI/StreamingleControlPanel");
|
|
|
|
if (visualTree == null)
|
|
{
|
|
logError("RuntimeControlPanel UXML을 찾을 수 없습니다. (Resources/StreamingleUI/StreamingleControlPanel)");
|
|
return;
|
|
}
|
|
|
|
// PanelSettings 생성
|
|
panelSettings = ScriptableObject.CreateInstance<PanelSettings>();
|
|
panelSettings.scaleMode = PanelScaleMode.ScaleWithScreenSize;
|
|
panelSettings.referenceResolution = new Vector2Int(1920, 1080);
|
|
panelSettings.screenMatchMode = PanelScreenMatchMode.MatchWidthOrHeight;
|
|
panelSettings.match = 0.5f;
|
|
panelSettings.sortingOrder = 100;
|
|
|
|
// 기본 런타임 테마 설정
|
|
SetThemeStyleSheet(panelSettings);
|
|
|
|
// UI GameObject 생성
|
|
var go = new GameObject("RuntimeControlPanel");
|
|
go.transform.SetParent(parent);
|
|
|
|
// UIDocument 설정
|
|
uiDocument = go.AddComponent<UIDocument>();
|
|
uiDocument.panelSettings = panelSettings;
|
|
uiDocument.visualTreeAsset = visualTree;
|
|
|
|
initStarted = true;
|
|
log("RuntimeControlPanel 오브젝트 생성 완료, UI 빌드 대기 중...");
|
|
}
|
|
|
|
private void SetupUI()
|
|
{
|
|
currentCategory = "camera";
|
|
catButtons = new Dictionary<string, Button>();
|
|
|
|
panelRoot = root.Q("panel-root");
|
|
|
|
catTitle = root.Q<Label>("cat-title");
|
|
catDesc = root.Q<Label>("cat-desc");
|
|
actionList = root.Q<ScrollView>("action-list");
|
|
|
|
wsDot = root.Q("ws-dot");
|
|
wsValue = root.Q<Label>("ws-value");
|
|
recDot = root.Q("rec-dot");
|
|
optitrackDot = root.Q("optitrack-dot");
|
|
facialValue = root.Q<Label>("facial-value");
|
|
retargetingDot = root.Q("retargeting-dot");
|
|
|
|
string[] categories = { "camera", "item", "event", "avatar", "system" };
|
|
foreach (var cat in categories)
|
|
{
|
|
var btn = root.Q<Button>($"btn-{cat}");
|
|
if (btn != null)
|
|
{
|
|
string captured = cat;
|
|
btn.clicked += () => SwitchCategory(captured);
|
|
catButtons[cat] = btn;
|
|
}
|
|
}
|
|
|
|
isVisible = false;
|
|
panelRoot.style.display = DisplayStyle.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// SystemController.Update()에서 매 프레임 호출
|
|
/// </summary>
|
|
public void Tick()
|
|
{
|
|
if (!panelEnabled) return;
|
|
|
|
// 지연 초기화: rootVisualElement가 준비될 때까지 대기
|
|
if (!isInitialized)
|
|
{
|
|
if (!initStarted || uiDocument == null) return;
|
|
|
|
root = uiDocument.rootVisualElement;
|
|
if (root == null || root.Q("panel-root") == null) return;
|
|
|
|
var styleSheet = Resources.Load<StyleSheet>("StreamingleUI/StreamingleControlPanel");
|
|
if (styleSheet != null)
|
|
root.styleSheets.Add(styleSheet);
|
|
|
|
SetupUI();
|
|
isInitialized = true;
|
|
log?.Invoke("RuntimeControlPanel 초기화 완료");
|
|
return;
|
|
}
|
|
|
|
if (Input.GetKeyDown(KeyCode.Escape))
|
|
TogglePanel();
|
|
|
|
if (!isVisible) return;
|
|
|
|
if (manager == null)
|
|
{
|
|
manager = StreamDeckServerManager.Instance;
|
|
if (manager == null) return;
|
|
SwitchCategory(currentCategory);
|
|
}
|
|
|
|
if (Time.unscaledTime - lastStatusUpdate >= STATUS_INTERVAL)
|
|
{
|
|
lastStatusUpdate = Time.unscaledTime;
|
|
UpdateStatusBar();
|
|
}
|
|
}
|
|
|
|
private void TogglePanel()
|
|
{
|
|
isVisible = !isVisible;
|
|
if (panelRoot == null) return;
|
|
panelRoot.style.display = isVisible ? DisplayStyle.Flex : DisplayStyle.None;
|
|
|
|
if (isVisible && manager != null)
|
|
SwitchCategory(currentCategory);
|
|
}
|
|
|
|
// ================================================================
|
|
// Category Switching
|
|
// ================================================================
|
|
|
|
private void SwitchCategory(string category)
|
|
{
|
|
currentCategory = category;
|
|
|
|
foreach (var kvp in catButtons)
|
|
{
|
|
if (kvp.Key == category)
|
|
kvp.Value.AddToClassList("cat-btn--active");
|
|
else
|
|
kvp.Value.RemoveFromClassList("cat-btn--active");
|
|
}
|
|
|
|
actionList.Clear();
|
|
|
|
switch (category)
|
|
{
|
|
case "camera": PopulateCamera(); break;
|
|
case "item": PopulateItems(); break;
|
|
case "event": PopulateEvents(); break;
|
|
case "avatar": PopulateAvatars(); break;
|
|
case "system": PopulateSystem(); break;
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Camera
|
|
// ================================================================
|
|
|
|
private void PopulateCamera()
|
|
{
|
|
catTitle.text = "Camera Control";
|
|
catDesc.text = "카메라 프리셋을 전환합니다. 활성 카메라는 초록색으로 표시됩니다.";
|
|
|
|
var cam = manager?.cameraManager;
|
|
if (cam == null)
|
|
{
|
|
actionList.Add(MakeErrorLabel("CameraManager를 찾을 수 없습니다."));
|
|
return;
|
|
}
|
|
|
|
var data = cam.GetCameraListData();
|
|
if (data?.presets == null) return;
|
|
|
|
foreach (var preset in data.presets)
|
|
{
|
|
var item = MakeActionItem(preset.index.ToString(), preset.name, preset.isActive);
|
|
|
|
var btn = MakeButton("Switch", preset.isActive ? "action-btn--success" : null);
|
|
int idx = preset.index;
|
|
btn.clicked += () =>
|
|
{
|
|
cam.Set(idx);
|
|
SwitchCategory("camera");
|
|
};
|
|
item.Add(btn);
|
|
actionList.Add(item);
|
|
}
|
|
|
|
}
|
|
|
|
// ================================================================
|
|
// Items
|
|
// ================================================================
|
|
|
|
private void PopulateItems()
|
|
{
|
|
catTitle.text = "Item Control";
|
|
catDesc.text = "아이템을 토글합니다. 활성 아이템은 초록색으로 표시됩니다.";
|
|
|
|
var ctrl = manager?.itemController;
|
|
if (ctrl == null)
|
|
{
|
|
actionList.Add(MakeErrorLabel("ItemController를 찾을 수 없습니다."));
|
|
return;
|
|
}
|
|
|
|
var topRow = new VisualElement();
|
|
topRow.AddToClassList("action-row");
|
|
|
|
var allOnBtn = MakeButton("All On", "action-btn--success");
|
|
allOnBtn.clicked += () => { ctrl.ActivateAllGroups(); SwitchCategory("item"); };
|
|
topRow.Add(allOnBtn);
|
|
|
|
var allOffBtn = MakeButton("All Off", "action-btn--secondary");
|
|
allOffBtn.clicked += () => { ctrl.DeactivateAllGroups(); SwitchCategory("item"); };
|
|
topRow.Add(allOffBtn);
|
|
|
|
actionList.Add(topRow);
|
|
|
|
var data = ctrl.GetItemListData();
|
|
if (data?.items == null) return;
|
|
|
|
foreach (var itemData in data.items)
|
|
{
|
|
var item = MakeActionItem(itemData.index.ToString(), itemData.name, itemData.isActive);
|
|
|
|
if (itemData.isActive)
|
|
{
|
|
var statusLabel = new Label("ON");
|
|
statusLabel.AddToClassList("action-item-status");
|
|
item.Add(statusLabel);
|
|
}
|
|
|
|
var btn = MakeButton("Toggle");
|
|
int idx = itemData.index;
|
|
btn.clicked += () =>
|
|
{
|
|
ctrl.ToggleGroup(idx);
|
|
SwitchCategory("item");
|
|
};
|
|
item.Add(btn);
|
|
actionList.Add(item);
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Events
|
|
// ================================================================
|
|
|
|
private void PopulateEvents()
|
|
{
|
|
catTitle.text = "Event Control";
|
|
catDesc.text = "이벤트를 실행합니다. 클릭 시 즉시 실행됩니다.";
|
|
|
|
var ctrl = manager?.eventController;
|
|
if (ctrl == null)
|
|
{
|
|
actionList.Add(MakeErrorLabel("EventController를 찾을 수 없습니다."));
|
|
return;
|
|
}
|
|
|
|
var data = ctrl.GetEventListData();
|
|
if (data?.events == null) return;
|
|
|
|
foreach (var evt in data.events)
|
|
{
|
|
var item = MakeActionItem(evt.index.ToString(), evt.name, false);
|
|
|
|
var btn = MakeButton("Execute");
|
|
int idx = evt.index;
|
|
btn.clicked += () => ctrl.ExecuteEvent(idx);
|
|
item.Add(btn);
|
|
actionList.Add(item);
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Avatars
|
|
// ================================================================
|
|
|
|
private void PopulateAvatars()
|
|
{
|
|
catTitle.text = "Avatar Outfit";
|
|
catDesc.text = "아바타 의상을 변경합니다. 현재 의상은 보라색으로 표시됩니다.";
|
|
|
|
var ctrl = manager?.avatarOutfitController;
|
|
if (ctrl == null)
|
|
{
|
|
actionList.Add(MakeErrorLabel("AvatarOutfitController를 찾을 수 없습니다."));
|
|
return;
|
|
}
|
|
|
|
var data = ctrl.GetAvatarOutfitListData();
|
|
if (data?.avatars == null) return;
|
|
|
|
foreach (var avatar in data.avatars)
|
|
{
|
|
var group = new VisualElement();
|
|
group.AddToClassList("avatar-group");
|
|
|
|
var nameLabel = new Label(avatar.name);
|
|
nameLabel.AddToClassList("avatar-group-name");
|
|
group.Add(nameLabel);
|
|
|
|
var outfitRow = new VisualElement();
|
|
outfitRow.AddToClassList("outfit-row");
|
|
|
|
if (avatar.outfits != null)
|
|
{
|
|
foreach (var outfit in avatar.outfits)
|
|
{
|
|
var btn = new Button { text = outfit.name };
|
|
btn.AddToClassList("outfit-btn");
|
|
if (outfit.index == avatar.current_outfit_index)
|
|
btn.AddToClassList("outfit-btn--active");
|
|
|
|
int avatarIdx = avatar.index;
|
|
int outfitIdx = outfit.index;
|
|
btn.clicked += () =>
|
|
{
|
|
ctrl.SetAvatarOutfit(avatarIdx, outfitIdx);
|
|
SwitchCategory("avatar");
|
|
};
|
|
outfitRow.Add(btn);
|
|
}
|
|
}
|
|
|
|
group.Add(outfitRow);
|
|
actionList.Add(group);
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// System
|
|
// ================================================================
|
|
|
|
private void PopulateSystem()
|
|
{
|
|
catTitle.text = "System Control";
|
|
catDesc.text = "스크린샷, 녹화, 모션캡처 등 시스템 기능을 제어합니다.";
|
|
|
|
var sys = manager?.systemController;
|
|
if (sys == null)
|
|
{
|
|
actionList.Add(MakeErrorLabel("SystemController를 찾을 수 없습니다."));
|
|
return;
|
|
}
|
|
|
|
// Screenshot
|
|
AddGroupTitle("Screenshot");
|
|
var ssRow = new VisualElement();
|
|
ssRow.AddToClassList("action-row");
|
|
AddSystemButton(ssRow, "Screenshot", "capture_screenshot");
|
|
AddSystemButton(ssRow, "Alpha", "capture_alpha_screenshot");
|
|
AddSystemButton(ssRow, "Open Folder", "open_screenshot_folder", "action-btn--secondary");
|
|
actionList.Add(ssRow);
|
|
|
|
// Motion Recording
|
|
AddGroupTitle("Motion Recording");
|
|
var recRow = new VisualElement();
|
|
recRow.AddToClassList("action-row");
|
|
bool isRec = sys.IsRecording();
|
|
if (isRec)
|
|
AddSystemButton(recRow, "Stop Rec", "stop_motion_recording", "action-btn--danger");
|
|
else
|
|
AddSystemButton(recRow, "Start Rec", "start_motion_recording", "action-btn--danger");
|
|
actionList.Add(recRow);
|
|
|
|
// OptiTrack
|
|
AddGroupTitle("OptiTrack");
|
|
var optiRow = new VisualElement();
|
|
optiRow.AddToClassList("action-row");
|
|
AddSystemButton(optiRow, "Reconnect", "reconnect_optitrack", "action-btn--secondary");
|
|
AddSystemButton(optiRow, "Toggle Markers", "toggle_optitrack_markers", "action-btn--secondary");
|
|
actionList.Add(optiRow);
|
|
|
|
// Facial Motion
|
|
AddGroupTitle("Facial Motion");
|
|
var facialRow = new VisualElement();
|
|
facialRow.AddToClassList("action-row");
|
|
AddSystemButton(facialRow, "Reconnect", "reconnect_facial_motion", "action-btn--secondary");
|
|
AddSystemButton(facialRow, "Refresh Clients", "refresh_facial_motion_clients", "action-btn--secondary");
|
|
actionList.Add(facialRow);
|
|
|
|
// Cloth Simulation
|
|
AddGroupTitle("Cloth Simulation");
|
|
var clothRow = new VisualElement();
|
|
clothRow.AddToClassList("action-row");
|
|
AddSystemButton(clothRow, "Reset Cloth", "reset_magica_cloth", "action-btn--secondary");
|
|
AddSystemButton(clothRow, "Reset (Keep Pose)", "reset_magica_cloth_keep_pose", "action-btn--secondary");
|
|
actionList.Add(clothRow);
|
|
|
|
// Retargeting Remote
|
|
AddGroupTitle("Retargeting Remote");
|
|
var rtRow = new VisualElement();
|
|
rtRow.AddToClassList("action-row");
|
|
bool rtRunning = sys.IsRetargetingRemoteRunning();
|
|
AddSystemButton(rtRow, rtRunning ? "Stop Remote" : "Start Remote",
|
|
"toggle_retargeting_remote", rtRunning ? "action-btn--danger" : "action-btn--success");
|
|
AddSystemButton(rtRow, "Refresh Characters", "refresh_retargeting_characters", "action-btn--secondary");
|
|
actionList.Add(rtRow);
|
|
}
|
|
|
|
private void AddGroupTitle(string title)
|
|
{
|
|
var label = new Label(title);
|
|
label.AddToClassList("group-title");
|
|
actionList.Add(label);
|
|
}
|
|
|
|
private void AddSystemButton(VisualElement row, string text, string command, string extraClass = null)
|
|
{
|
|
var btn = MakeButton(text, extraClass);
|
|
btn.clicked += () =>
|
|
{
|
|
manager.systemController.ExecuteCommand(command, new Dictionary<string, object>());
|
|
if (command.Contains("recording") || command.Contains("retargeting"))
|
|
{
|
|
root.schedule.Execute(() => SwitchCategory("system")).ExecuteLater(100);
|
|
}
|
|
};
|
|
row.Add(btn);
|
|
}
|
|
|
|
// ================================================================
|
|
// Status Bar
|
|
// ================================================================
|
|
|
|
private void UpdateStatusBar()
|
|
{
|
|
if (manager == null) return;
|
|
|
|
int clientCount = manager.ConnectedClientCount;
|
|
SetDot(wsDot, clientCount > 0);
|
|
if (wsValue != null) wsValue.text = clientCount.ToString();
|
|
|
|
var sys = manager.systemController;
|
|
if (sys == null) return;
|
|
|
|
bool isRec = sys.IsRecording();
|
|
SetDot(recDot, isRec, true);
|
|
|
|
bool optiConnected = sys.optiTrack?.IsOptitrackConnected() ?? false;
|
|
SetDot(optitrackDot, optiConnected);
|
|
|
|
int facialCount = sys.facialMotion?.facialMotionClients?.Count ?? 0;
|
|
if (facialValue != null) facialValue.text = facialCount.ToString();
|
|
|
|
bool rtRunning = sys.IsRetargetingRemoteRunning();
|
|
SetDot(retargetingDot, rtRunning);
|
|
}
|
|
|
|
private void SetDot(VisualElement dot, bool active, bool isRecording = false)
|
|
{
|
|
if (dot == null) return;
|
|
dot.RemoveFromClassList("status-dot--on");
|
|
dot.RemoveFromClassList("status-dot--rec");
|
|
|
|
if (active)
|
|
dot.AddToClassList(isRecording ? "status-dot--rec" : "status-dot--on");
|
|
}
|
|
|
|
// ================================================================
|
|
// UI Helpers
|
|
// ================================================================
|
|
|
|
private VisualElement MakeActionItem(string index, string label, bool active)
|
|
{
|
|
var item = new VisualElement();
|
|
item.AddToClassList("action-item");
|
|
if (active) item.AddToClassList("action-item--active");
|
|
|
|
var idxLabel = new Label($"[{index}]");
|
|
idxLabel.AddToClassList("action-item-index");
|
|
item.Add(idxLabel);
|
|
|
|
var nameLabel = new Label(label);
|
|
nameLabel.AddToClassList("action-item-label");
|
|
item.Add(nameLabel);
|
|
|
|
return item;
|
|
}
|
|
|
|
private Button MakeButton(string text, string extraClass = null)
|
|
{
|
|
var btn = new Button { text = text };
|
|
btn.AddToClassList("action-btn");
|
|
if (!string.IsNullOrEmpty(extraClass))
|
|
btn.AddToClassList(extraClass);
|
|
return btn;
|
|
}
|
|
|
|
private Label MakeErrorLabel(string message)
|
|
{
|
|
var label = new Label(message);
|
|
label.style.color = new StyleColor(new Color(0.94f, 0.27f, 0.27f));
|
|
label.style.fontSize = 13;
|
|
label.style.marginTop = 20;
|
|
return label;
|
|
}
|
|
|
|
private static void SetThemeStyleSheet(PanelSettings ps)
|
|
{
|
|
// 1) 이미 로드된 테마 검색
|
|
var loaded = Resources.FindObjectsOfTypeAll<ThemeStyleSheet>();
|
|
if (loaded.Length > 0)
|
|
{
|
|
ps.themeStyleSheet = loaded[0];
|
|
return;
|
|
}
|
|
|
|
#if UNITY_EDITOR
|
|
// 2) AssetDatabase에서 모든 ThemeStyleSheet 검색 (패키지 포함)
|
|
var guids = UnityEditor.AssetDatabase.FindAssets("t:ThemeStyleSheet");
|
|
if (guids.Length > 0)
|
|
{
|
|
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
|
|
var theme = UnityEditor.AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(path);
|
|
if (theme != null)
|
|
{
|
|
ps.themeStyleSheet = theme;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3) 아무것도 없으면 빈 테마 생성
|
|
ps.themeStyleSheet = ScriptableObject.CreateInstance<ThemeStyleSheet>();
|
|
#endif
|
|
}
|
|
|
|
public void Cleanup()
|
|
{
|
|
if (panelSettings != null)
|
|
UnityEngine.Object.Destroy(panelSettings);
|
|
if (uiDocument != null)
|
|
UnityEngine.Object.Destroy(uiDocument.gameObject);
|
|
}
|
|
}
|