user 41270a34f5 Refactor: 전체 에디터 UXML 전환 + 대시보드/런타임 UI + 한글화 + NanumGothic 폰트
- 모든 컨트롤러 에디터를 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>
2026-02-16 02:51:43 +09:00

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);
}
}