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>
This commit is contained in:
parent
2f6ddc3ccb
commit
41270a34f5
@ -1,81 +1,148 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
|
||||||
[CustomEditor(typeof(OptitrackStreamingClient))]
|
[CustomEditor(typeof(OptitrackStreamingClient))]
|
||||||
public class OptitrackStreamingClientEditor : Editor
|
public class OptitrackStreamingClientEditor : Editor
|
||||||
{
|
{
|
||||||
public override void OnInspectorGUI()
|
private const string UxmlPath = "Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/Editor/UXML/OptitrackStreamingClientEditor.uxml";
|
||||||
|
private const string UssPath = "Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/Editor/UXML/OptitrackStreamingClientEditor.uss";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private OptitrackStreamingClient client;
|
||||||
|
private VisualElement statusDot;
|
||||||
|
private Label statusText;
|
||||||
|
private VisualElement runtimeOffline;
|
||||||
|
private VisualElement runtimeOnline;
|
||||||
|
private VisualElement runtimeInfo;
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
{
|
{
|
||||||
// 기본 Inspector 그리기
|
client = (OptitrackStreamingClient)target;
|
||||||
DrawDefaultInspector();
|
var root = new VisualElement();
|
||||||
|
|
||||||
EditorGUILayout.Space();
|
// Load stylesheets
|
||||||
EditorGUILayout.LabelField("OptiTrack 연결 제어", EditorStyles.boldLabel);
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
OptitrackStreamingClient client = (OptitrackStreamingClient)target;
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
|
if (uss != null) root.styleSheets.Add(uss);
|
||||||
|
|
||||||
// 연결 상태 표시
|
// Load UXML
|
||||||
EditorGUILayout.BeginHorizontal();
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
EditorGUILayout.LabelField("연결 상태:", GUILayout.Width(80));
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
string connectionStatus = Application.isPlaying ? client.GetConnectionStatus() : "게임 실행 중이 아님";
|
// Cache references
|
||||||
Color originalColor = GUI.color;
|
statusDot = root.Q("statusDot");
|
||||||
|
statusText = root.Q<Label>("statusText");
|
||||||
|
runtimeOffline = root.Q("runtimeOffline");
|
||||||
|
runtimeOnline = root.Q("runtimeOnline");
|
||||||
|
runtimeInfo = root.Q("runtimeInfo");
|
||||||
|
|
||||||
if (Application.isPlaying)
|
// Reconnect button
|
||||||
|
var reconnectBtn = root.Q<Button>("reconnectBtn");
|
||||||
|
if (reconnectBtn != null)
|
||||||
|
reconnectBtn.clicked += () => { if (Application.isPlaying) client.Reconnect(); };
|
||||||
|
|
||||||
|
// Play mode polling
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(300);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePlayModeState()
|
||||||
|
{
|
||||||
|
if (client == null) return;
|
||||||
|
|
||||||
|
bool isPlaying = Application.isPlaying;
|
||||||
|
|
||||||
|
// Toggle runtime sections
|
||||||
|
if (isPlaying)
|
||||||
{
|
{
|
||||||
if (client.IsConnected())
|
if (!runtimeOnline.ClassListContains("opti-runtime-online--visible"))
|
||||||
{
|
runtimeOnline.AddToClassList("opti-runtime-online--visible");
|
||||||
GUI.color = Color.green;
|
if (!runtimeOffline.ClassListContains("opti-runtime-offline--hidden"))
|
||||||
}
|
runtimeOffline.AddToClassList("opti-runtime-offline--hidden");
|
||||||
else
|
|
||||||
{
|
|
||||||
GUI.color = Color.red;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
GUI.color = Color.gray;
|
if (runtimeOnline.ClassListContains("opti-runtime-online--visible"))
|
||||||
|
runtimeOnline.RemoveFromClassList("opti-runtime-online--visible");
|
||||||
|
if (runtimeOffline.ClassListContains("opti-runtime-offline--hidden"))
|
||||||
|
runtimeOffline.RemoveFromClassList("opti-runtime-offline--hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorGUILayout.LabelField(connectionStatus, EditorStyles.boldLabel);
|
// Update status badge
|
||||||
GUI.color = originalColor;
|
if (isPlaying)
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
EditorGUILayout.Space();
|
|
||||||
|
|
||||||
// 재접속 버튼
|
|
||||||
EditorGUI.BeginDisabledGroup(!Application.isPlaying);
|
|
||||||
|
|
||||||
if (GUILayout.Button("OptiTrack 재접속", GUILayout.Height(30)))
|
|
||||||
{
|
{
|
||||||
client.Reconnect();
|
bool connected = client.IsConnected();
|
||||||
|
string status = client.GetConnectionStatus();
|
||||||
|
|
||||||
|
SetStatusStyle(connected);
|
||||||
|
if (statusText != null)
|
||||||
|
statusText.text = status;
|
||||||
|
|
||||||
|
RebuildRuntimeInfo();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
EditorGUI.EndDisabledGroup();
|
|
||||||
|
|
||||||
if (!Application.isPlaying)
|
|
||||||
{
|
{
|
||||||
EditorGUILayout.HelpBox("재접속 기능은 게임이 실행 중일 때만 사용할 수 있습니다.", MessageType.Info);
|
SetStatusStyle(null);
|
||||||
}
|
if (statusText != null)
|
||||||
|
statusText.text = "Stopped";
|
||||||
EditorGUILayout.Space();
|
|
||||||
|
|
||||||
// 추가 정보 표시
|
|
||||||
if (Application.isPlaying)
|
|
||||||
{
|
|
||||||
EditorGUILayout.LabelField("서버 정보", EditorStyles.boldLabel);
|
|
||||||
EditorGUILayout.LabelField("서버 주소:", client.ServerAddress);
|
|
||||||
EditorGUILayout.LabelField("로컬 주소:", client.LocalAddress);
|
|
||||||
EditorGUILayout.LabelField("연결 유형:", client.ConnectionType.ToString());
|
|
||||||
EditorGUILayout.LabelField("서버 NatNet 버전:", client.ServerNatNetVersion);
|
|
||||||
EditorGUILayout.LabelField("클라이언트 NatNet 버전:", client.ClientNatNetVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inspector를 지속적으로 업데이트 (실시간 연결 상태 표시를 위해)
|
|
||||||
if (Application.isPlaying)
|
|
||||||
{
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
Repaint();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetStatusStyle(bool? connected)
|
||||||
|
{
|
||||||
|
if (statusDot == null || statusText == null) return;
|
||||||
|
|
||||||
|
// Clear all states
|
||||||
|
statusDot.RemoveFromClassList("opti-status-dot--connected");
|
||||||
|
statusDot.RemoveFromClassList("opti-status-dot--disconnected");
|
||||||
|
statusText.RemoveFromClassList("opti-status-text--connected");
|
||||||
|
statusText.RemoveFromClassList("opti-status-text--disconnected");
|
||||||
|
|
||||||
|
if (connected == true)
|
||||||
|
{
|
||||||
|
statusDot.AddToClassList("opti-status-dot--connected");
|
||||||
|
statusText.AddToClassList("opti-status-text--connected");
|
||||||
|
}
|
||||||
|
else if (connected == false)
|
||||||
|
{
|
||||||
|
statusDot.AddToClassList("opti-status-dot--disconnected");
|
||||||
|
statusText.AddToClassList("opti-status-text--disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildRuntimeInfo()
|
||||||
|
{
|
||||||
|
if (runtimeInfo == null || client == null) return;
|
||||||
|
runtimeInfo.Clear();
|
||||||
|
|
||||||
|
AddInfoRow(runtimeInfo, "Server Address", client.ServerAddress);
|
||||||
|
AddInfoRow(runtimeInfo, "Local Address", client.LocalAddress);
|
||||||
|
AddInfoRow(runtimeInfo, "Connection Type", client.ConnectionType.ToString());
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(client.ServerNatNetVersion))
|
||||||
|
AddInfoRow(runtimeInfo, "Server NatNet", client.ServerNatNetVersion);
|
||||||
|
if (!string.IsNullOrEmpty(client.ClientNatNetVersion))
|
||||||
|
AddInfoRow(runtimeInfo, "Client NatNet", client.ClientNatNetVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddInfoRow(VisualElement parent, string label, string value)
|
||||||
|
{
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.AddToClassList("opti-info-row");
|
||||||
|
|
||||||
|
var lbl = new Label(label);
|
||||||
|
lbl.AddToClassList("opti-info-label");
|
||||||
|
row.Add(lbl);
|
||||||
|
|
||||||
|
var val = new Label(value);
|
||||||
|
val.AddToClassList("opti-info-value");
|
||||||
|
row.Add(val);
|
||||||
|
|
||||||
|
parent.Add(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 53a33bd699913a642832d9cefe527f21
|
guid: a2ec7617f65956541ad157e05b2c005b
|
||||||
folderAsset: yes
|
folderAsset: yes
|
||||||
DefaultImporter:
|
DefaultImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
/* OptiTrack Streaming Client Editor Styles */
|
||||||
|
|
||||||
|
/* ---- Title Bar ---- */
|
||||||
|
|
||||||
|
.opti-title-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-title-text {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-badge {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #6b7280;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-dot--connected {
|
||||||
|
background-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-dot--disconnected {
|
||||||
|
background-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-text {
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-text--connected {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-status-text--disconnected {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Runtime Controls ---- */
|
||||||
|
|
||||||
|
.opti-runtime-offline {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-runtime-online {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-runtime-online--visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-runtime-offline--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-reconnect-btn {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
padding: 5px 14px;
|
||||||
|
height: 28px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-reconnect-btn:hover {
|
||||||
|
background-color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-reconnect-btn:active {
|
||||||
|
background-color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-runtime-info {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-info-row {
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opti-info-value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8fcfddbe9eb7ba643bb55b3fe2832282
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- Title Bar -->
|
||||||
|
<ui:VisualElement name="titleBar" class="opti-title-bar">
|
||||||
|
<ui:Label text="OptiTrack Streaming Client" class="opti-title-text"/>
|
||||||
|
<ui:VisualElement name="statusBadge" class="opti-status-badge">
|
||||||
|
<ui:VisualElement name="statusDot" class="opti-status-dot"/>
|
||||||
|
<ui:Label name="statusText" text="Stopped" class="opti-status-text"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Connection Settings -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Connection Settings" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="ServerAddress" label="Server Address"/>
|
||||||
|
<uie:PropertyField binding-path="LocalAddress" label="Local Address"/>
|
||||||
|
<uie:PropertyField binding-path="ConnectionType" label="Connection Type"/>
|
||||||
|
<uie:PropertyField binding-path="SkeletonCoordinates" label="Skeleton Coordinates"/>
|
||||||
|
<uie:PropertyField binding-path="TMarkersetCoordinates" label="TMarkerset Coordinates"/>
|
||||||
|
<uie:PropertyField binding-path="BoneNamingConvention" label="Bone Naming Convention"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Extra Features -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Extra Features" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="DrawMarkers" label="Draw Markers"/>
|
||||||
|
<uie:PropertyField binding-path="DrawTMarkersetMarkers" label="Draw TMarkerset Markers"/>
|
||||||
|
<uie:PropertyField binding-path="DrawCameras" label="Draw Cameras"/>
|
||||||
|
<uie:PropertyField binding-path="DrawForcePlates" label="Draw Force Plates"/>
|
||||||
|
<uie:PropertyField binding-path="RecordOnPlay" label="Record On Play"/>
|
||||||
|
<uie:PropertyField binding-path="SkipDataDescriptions" label="Skip Data Descriptions"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- NatNet Version -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="NatNet Version" value="false" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="ServerNatNetVersion" label="Server Version"/>
|
||||||
|
<uie:PropertyField binding-path="ClientNatNetVersion" label="Client Version"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Runtime Controls (built dynamically) -->
|
||||||
|
<ui:VisualElement name="runtimeSection" class="section">
|
||||||
|
<ui:Foldout text="Runtime Controls" value="true" class="section-foldout">
|
||||||
|
<ui:VisualElement name="runtimeOffline" class="opti-runtime-offline">
|
||||||
|
<ui:HelpBox message-type="Info" text="재접속 기능은 플레이 모드에서만 사용할 수 있습니다."/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement name="runtimeOnline" class="opti-runtime-online">
|
||||||
|
<ui:Button name="reconnectBtn" text="OptiTrack Reconnect" class="opti-reconnect-btn"/>
|
||||||
|
<ui:VisualElement name="runtimeInfo" class="opti-runtime-info"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e5982fe4cc6a11640a72e87132c66e5e
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -1,533 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright © 2016 NaturalPoint Inc.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Implements live retargeting of streamed OptiTrack skeletal data via Mecanim.
|
|
||||||
/// Add this component to an imported model GameObject that has a properly configured humanoid Avatar definition.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A hierarchy of GameObjects (see <see cref="m_rootObject"/> and <see cref="m_boneObjectMap"/>) will be created to
|
|
||||||
/// receive the streaming pose data for the skeleton asset specified by <see cref="SkeletonAssetName"/>.
|
|
||||||
/// A source Avatar will be created automatically from the streamed skeleton definition.
|
|
||||||
/// </remarks>
|
|
||||||
public class OptitrackSkeletonAnimator : MonoBehaviour
|
|
||||||
{
|
|
||||||
/// <summary>The client object to use for receiving streamed skeletal pose data.</summary>
|
|
||||||
[Tooltip("The object containing the OptiTrackStreamingClient script.")]
|
|
||||||
public OptitrackStreamingClient StreamingClient;
|
|
||||||
|
|
||||||
/// <summary>The name of the skeleton asset in the stream that will provide retargeting source data.</summary>
|
|
||||||
[Tooltip("The name of skeleton asset in Motive.")]
|
|
||||||
public string SkeletonAssetName = "Skeleton1";
|
|
||||||
|
|
||||||
/// <summary>The humanoid avatar for this GameObject's imported model.</summary>
|
|
||||||
[Tooltip("The humanoid avatar model.")]
|
|
||||||
public Avatar DestinationAvatar;
|
|
||||||
|
|
||||||
#region Private fields
|
|
||||||
/// <summary>Used when retrieving and retargeting source pose. Cached and reused for efficiency.</summary>
|
|
||||||
private HumanPose m_humanPose = new HumanPose();
|
|
||||||
|
|
||||||
/// <summary>The streamed source skeleton definition.</summary>
|
|
||||||
private OptitrackSkeletonDefinition m_skeletonDef;
|
|
||||||
|
|
||||||
/// <summary>The root GameObject of the streamed skeletal pose transform hierarchy.</summary>
|
|
||||||
private GameObject m_rootObject;
|
|
||||||
|
|
||||||
/// <summary>Maps between OptiTrack skeleton bone IDs and corresponding GameObjects.</summary>
|
|
||||||
private Dictionary<Int32, GameObject> m_boneObjectMap;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps between Mecanim anatomy bone names (keys) and streamed bone names from various naming conventions
|
|
||||||
/// supported by the OptiTrack software (values). Populated by <see cref="CacheBoneNameMap"/>.
|
|
||||||
/// </summary>
|
|
||||||
private Dictionary<string, string> m_cachedMecanimBoneNameMap = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
/// <summary>Created automatically based on <see cref="m_skeletonDef"/>.</summary>
|
|
||||||
private Avatar m_srcAvatar;
|
|
||||||
|
|
||||||
/// <summary>Set up to read poses from our streamed GameObject transform hierarchy.</summary>
|
|
||||||
private HumanPoseHandler m_srcPoseHandler;
|
|
||||||
|
|
||||||
/// <summary>Set up to write poses to this GameObject using <see cref="DestinationAvatar"/>.</summary>
|
|
||||||
private HumanPoseHandler m_destPoseHandler;
|
|
||||||
#endregion Private fields
|
|
||||||
|
|
||||||
void Start()
|
|
||||||
{
|
|
||||||
// If the user didn't explicitly associate a client, find a suitable default.
|
|
||||||
if ( this.StreamingClient == null )
|
|
||||||
{
|
|
||||||
this.StreamingClient = OptitrackStreamingClient.FindDefaultClient();
|
|
||||||
|
|
||||||
// If we still couldn't find one, disable this component.
|
|
||||||
if ( this.StreamingClient == null )
|
|
||||||
{
|
|
||||||
Debug.LogError( GetType().FullName + ": Streaming client not set, and no " + typeof( OptitrackStreamingClient ).FullName + " components found in scene; disabling this component.", this );
|
|
||||||
this.enabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.StreamingClient.RegisterSkeleton(this, this.SkeletonAssetName);
|
|
||||||
|
|
||||||
// Create a lookup from Mecanim anatomy bone names to OptiTrack streaming bone names.
|
|
||||||
CacheBoneNameMap( this.StreamingClient.BoneNamingConvention, this.SkeletonAssetName );
|
|
||||||
|
|
||||||
// Retrieve the OptiTrack skeleton definition.
|
|
||||||
m_skeletonDef = this.StreamingClient.GetSkeletonDefinitionByName( this.SkeletonAssetName );
|
|
||||||
|
|
||||||
if ( m_skeletonDef == null )
|
|
||||||
{
|
|
||||||
Debug.LogError( GetType().FullName + ": Could not find skeleton definition with the name \"" + this.SkeletonAssetName + "\"", this );
|
|
||||||
this.enabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a hierarchy of GameObjects that will receive the skeletal pose data.
|
|
||||||
string rootObjectName = "OptiTrack Skeleton - " + this.SkeletonAssetName;
|
|
||||||
m_rootObject = new GameObject( rootObjectName );
|
|
||||||
|
|
||||||
m_boneObjectMap = new Dictionary<Int32, GameObject>( m_skeletonDef.Bones.Count );
|
|
||||||
|
|
||||||
for ( int boneDefIdx = 0; boneDefIdx < m_skeletonDef.Bones.Count; ++boneDefIdx )
|
|
||||||
{
|
|
||||||
OptitrackSkeletonDefinition.BoneDefinition boneDef = m_skeletonDef.Bones[boneDefIdx];
|
|
||||||
|
|
||||||
GameObject boneObject = new GameObject( boneDef.Name );
|
|
||||||
boneObject.transform.parent = boneDef.ParentId == 0 ? m_rootObject.transform : m_boneObjectMap[boneDef.ParentId].transform;
|
|
||||||
boneObject.transform.localPosition = boneDef.Offset;
|
|
||||||
m_boneObjectMap[boneDef.Id] = boneObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook up retargeting between those GameObjects and the destination Avatar.
|
|
||||||
MecanimSetup( rootObjectName );
|
|
||||||
|
|
||||||
// Can't re-parent this until after Mecanim setup, or else Mecanim gets confused.
|
|
||||||
m_rootObject.transform.parent = this.StreamingClient.transform;
|
|
||||||
m_rootObject.transform.localPosition = Vector3.zero;
|
|
||||||
m_rootObject.transform.localRotation = Quaternion.identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void Update()
|
|
||||||
{
|
|
||||||
OptitrackSkeletonState skelState = StreamingClient.GetLatestSkeletonState(m_skeletonDef.Id);
|
|
||||||
if (skelState != null)
|
|
||||||
{
|
|
||||||
// Update the transforms of the bone GameObjects.
|
|
||||||
for (int i = 0; i < m_skeletonDef.Bones.Count; ++i)
|
|
||||||
{
|
|
||||||
Int32 boneId = m_skeletonDef.Bones[i].Id;
|
|
||||||
string boneName = m_skeletonDef.Bones[i].Name;
|
|
||||||
|
|
||||||
OptitrackPose bonePose;
|
|
||||||
GameObject boneObject;
|
|
||||||
|
|
||||||
bool foundPose = false;
|
|
||||||
if (StreamingClient.SkeletonCoordinates == StreamingCoordinatesValues.Global)
|
|
||||||
{
|
|
||||||
foundPose = skelState.LocalBonePoses.TryGetValue(boneId, out bonePose);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foundPose = skelState.BonePoses.TryGetValue(boneId, out bonePose);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool foundObject = m_boneObjectMap.TryGetValue(boneId, out boneObject);
|
|
||||||
if (foundPose && foundObject)
|
|
||||||
{
|
|
||||||
// 손가락 본인 경우 회전 데이터는 무시하고 위치 데이터만 적용
|
|
||||||
if (IsFingerBone(boneName))
|
|
||||||
{
|
|
||||||
boneObject.transform.localPosition = bonePose.Position;
|
|
||||||
// 회전은 기본값 유지
|
|
||||||
boneObject.transform.localRotation = Quaternion.identity;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 손가락이 아닌 다른 본들은 모든 데이터 적용
|
|
||||||
boneObject.transform.localPosition = bonePose.Position;
|
|
||||||
boneObject.transform.localRotation = bonePose.Orientation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform Mecanim retargeting.
|
|
||||||
if (m_srcPoseHandler != null && m_destPoseHandler != null)
|
|
||||||
{
|
|
||||||
m_srcPoseHandler.GetHumanPose(ref m_humanPose);
|
|
||||||
m_destPoseHandler.SetHumanPose(ref m_humanPose);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 손가락 본인지 확인하는 헬퍼 메서드
|
|
||||||
private bool IsFingerBone(string boneName)
|
|
||||||
{
|
|
||||||
return boneName.Contains("Thumb") ||
|
|
||||||
boneName.Contains("Index") ||
|
|
||||||
boneName.Contains("Middle") ||
|
|
||||||
boneName.Contains("Ring") ||
|
|
||||||
boneName.Contains("Pinky") ||
|
|
||||||
boneName.Contains("Finger");
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Private methods
|
|
||||||
/// <summary>
|
|
||||||
/// Constructs the source Avatar and pose handlers for Mecanim retargeting.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="rootObjectName"></param>
|
|
||||||
private void MecanimSetup( string rootObjectName )
|
|
||||||
{
|
|
||||||
string[] humanTraitBoneNames = HumanTrait.BoneName;
|
|
||||||
|
|
||||||
// Set up the mapping between Mecanim human anatomy and OptiTrack skeleton representations.
|
|
||||||
List<HumanBone> humanBones = new List<HumanBone>( m_skeletonDef.Bones.Count );
|
|
||||||
for ( int humanBoneNameIdx = 0; humanBoneNameIdx < humanTraitBoneNames.Length; ++humanBoneNameIdx )
|
|
||||||
{
|
|
||||||
string humanBoneName = humanTraitBoneNames[humanBoneNameIdx];
|
|
||||||
if ( m_cachedMecanimBoneNameMap.ContainsKey( humanBoneName ) )
|
|
||||||
{
|
|
||||||
HumanBone humanBone = new HumanBone();
|
|
||||||
humanBone.humanName = humanBoneName;
|
|
||||||
humanBone.boneName = m_cachedMecanimBoneNameMap[humanBoneName];
|
|
||||||
humanBone.limit.useDefaultValues = true;
|
|
||||||
|
|
||||||
humanBones.Add( humanBone );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the T-pose and game object name mappings.
|
|
||||||
List<SkeletonBone> skeletonBones = new List<SkeletonBone>( m_skeletonDef.Bones.Count + 1 );
|
|
||||||
|
|
||||||
// Special case: Create the root bone.
|
|
||||||
{
|
|
||||||
SkeletonBone rootBone = new SkeletonBone();
|
|
||||||
rootBone.name = rootObjectName;
|
|
||||||
rootBone.position = Vector3.zero;
|
|
||||||
rootBone.rotation = Quaternion.identity;
|
|
||||||
rootBone.scale = Vector3.one;
|
|
||||||
|
|
||||||
skeletonBones.Add( rootBone );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create remaining re-targeted bone definitions.
|
|
||||||
for ( int boneDefIdx = 0; boneDefIdx < m_skeletonDef.Bones.Count; ++boneDefIdx )
|
|
||||||
{
|
|
||||||
OptitrackSkeletonDefinition.BoneDefinition boneDef = m_skeletonDef.Bones[boneDefIdx];
|
|
||||||
|
|
||||||
SkeletonBone skelBone = new SkeletonBone();
|
|
||||||
skelBone.name = boneDef.Name;
|
|
||||||
skelBone.position = boneDef.Offset;
|
|
||||||
skelBone.rotation = RemapBoneRotation(boneDef.Name); //Identity unless it's the thumb bone.
|
|
||||||
skelBone.scale = Vector3.one;
|
|
||||||
|
|
||||||
skeletonBones.Add( skelBone );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now set up the HumanDescription for the retargeting source Avatar.
|
|
||||||
HumanDescription humanDesc = new HumanDescription();
|
|
||||||
humanDesc.human = humanBones.ToArray();
|
|
||||||
humanDesc.skeleton = skeletonBones.ToArray();
|
|
||||||
|
|
||||||
// These all correspond to default values.
|
|
||||||
humanDesc.upperArmTwist = 0.5f;
|
|
||||||
humanDesc.lowerArmTwist = 0.5f;
|
|
||||||
humanDesc.upperLegTwist = 0.5f;
|
|
||||||
humanDesc.lowerLegTwist = 0.5f;
|
|
||||||
humanDesc.armStretch = 0.05f;
|
|
||||||
humanDesc.legStretch = 0.05f;
|
|
||||||
humanDesc.feetSpacing = 0.0f;
|
|
||||||
humanDesc.hasTranslationDoF = false;
|
|
||||||
|
|
||||||
// Finally, take the description and build the Avatar and pose handlers.
|
|
||||||
m_srcAvatar = AvatarBuilder.BuildHumanAvatar( m_rootObject, humanDesc );
|
|
||||||
|
|
||||||
if ( m_srcAvatar.isValid == false || m_srcAvatar.isHuman == false )
|
|
||||||
{
|
|
||||||
Debug.LogError( GetType().FullName + ": Unable to create source Avatar for retargeting. Check that your Skeleton Asset Name and Bone Naming Convention are configured correctly.", this );
|
|
||||||
this.enabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_srcPoseHandler = new HumanPoseHandler( m_srcAvatar, m_rootObject.transform );
|
|
||||||
m_destPoseHandler = new HumanPoseHandler( DestinationAvatar, this.transform );
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adjusts default position of the proximal thumb bones.
|
|
||||||
/// The default pose between Unity and Motive differs on the thumb, this fixes that difference.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="boneName">The name of the current bone.</param>
|
|
||||||
private Quaternion RemapBoneRotation(string boneName)
|
|
||||||
{
|
|
||||||
if (this.StreamingClient.BoneNamingConvention == OptitrackBoneNameConvention.Motive)
|
|
||||||
{
|
|
||||||
if (boneName.EndsWith("_LThumb1"))
|
|
||||||
{
|
|
||||||
// 60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, 0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
if (boneName.EndsWith("_RThumb1"))
|
|
||||||
{
|
|
||||||
// -60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, -0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.StreamingClient.BoneNamingConvention == OptitrackBoneNameConvention.FBX)
|
|
||||||
{
|
|
||||||
if (boneName.EndsWith("_LeftHandThumb1"))
|
|
||||||
{
|
|
||||||
// 60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, 0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
if (boneName.EndsWith("_RightHandThumb1"))
|
|
||||||
{
|
|
||||||
// -60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, -0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.StreamingClient.BoneNamingConvention == OptitrackBoneNameConvention.BVH)
|
|
||||||
{
|
|
||||||
if (boneName.EndsWith("_LeftFinger0"))
|
|
||||||
{
|
|
||||||
// 60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, 0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
if (boneName.EndsWith("_RightFinger0"))
|
|
||||||
{
|
|
||||||
// -60 Deg Y-Axis rotation
|
|
||||||
return new Quaternion(0.0f, -0.5000011f, 0.0f, 0.8660248f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Quaternion.identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the <see cref="m_cachedMecanimBoneNameMap"/> lookup to reflect the specified bone naming convention
|
|
||||||
/// and source skeleton asset name.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="convention">The bone naming convention to use. Must match the host software.</param>
|
|
||||||
/// <param name="assetName">The name of the source skeleton asset.</param>
|
|
||||||
private void CacheBoneNameMap( OptitrackBoneNameConvention convention, string assetName )
|
|
||||||
{
|
|
||||||
m_cachedMecanimBoneNameMap.Clear();
|
|
||||||
|
|
||||||
switch ( convention )
|
|
||||||
{
|
|
||||||
case OptitrackBoneNameConvention.Motive:
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Hips", assetName + "_Hip" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Spine", assetName + "_Ab" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Chest", assetName + "_Chest" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Neck", assetName + "_Neck" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Head", assetName + "_Head" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftShoulder", assetName + "_LShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperArm", assetName + "_LUArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerArm", assetName + "_LFArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftHand", assetName + "_LHand" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightShoulder", assetName + "_RShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperArm", assetName + "_RUArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerArm", assetName + "_RFArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightHand", assetName + "_RHand" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperLeg", assetName + "_LThigh" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerLeg", assetName + "_LShin" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftFoot", assetName + "_LFoot" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftToeBase", assetName + "_LToe" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperLeg", assetName + "_RThigh" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerLeg", assetName + "_RShin" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightFoot", assetName + "_RFoot" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightToeBase", assetName + "_RToe" );
|
|
||||||
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Proximal", assetName + "_LThumb1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Intermediate", assetName + "_LThumb2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Distal", assetName + "_LThumb3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Proximal", assetName + "_RThumb1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Intermediate", assetName + "_RThumb2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Distal", assetName + "_RThumb3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Proximal", assetName + "_LIndex1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Intermediate", assetName + "_LIndex2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Distal", assetName + "_LIndex3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Proximal", assetName + "_RIndex1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Intermediate", assetName + "_RIndex2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Distal", assetName + "_RIndex3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Proximal", assetName + "_LMiddle1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Intermediate", assetName + "_LMiddle2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Distal", assetName + "_LMiddle3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Proximal", assetName + "_RMiddle1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Intermediate", assetName + "_RMiddle2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Distal", assetName + "_RMiddle3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Proximal", assetName + "_LRing1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Intermediate", assetName + "_LRing2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Distal", assetName + "_LRing3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Proximal", assetName + "_RRing1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Intermediate", assetName + "_RRing2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Distal", assetName + "_RRing3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Proximal", assetName + "_LPinky1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Intermediate", assetName + "_LPinky2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Distal", assetName + "_LPinky3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Proximal", assetName + "_RPinky1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Intermediate", assetName + "_RPinky2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Distal", assetName + "_RPinky3" );
|
|
||||||
break;
|
|
||||||
case OptitrackBoneNameConvention.FBX:
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Hips", assetName + "_Hips" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Spine", assetName + "_Spine" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Chest", assetName + "_Spine1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Neck", assetName + "_Neck" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Head", assetName + "_Head" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftShoulder", assetName + "_LeftShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperArm", assetName + "_LeftArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerArm", assetName + "_LeftForeArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftHand", assetName + "_LeftHand" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightShoulder", assetName + "_RightShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperArm", assetName + "_RightArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerArm", assetName + "_RightForeArm" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightHand", assetName + "_RightHand" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperLeg", assetName + "_LeftUpLeg" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerLeg", assetName + "_LeftLeg" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftFoot", assetName + "_LeftFoot" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftToeBase", assetName + "_LeftToeBase" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperLeg", assetName + "_RightUpLeg" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerLeg", assetName + "_RightLeg" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightFoot", assetName + "_RightFoot" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightToeBase", assetName + "_RightToeBase" );
|
|
||||||
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Proximal", assetName + "_LeftHandThumb1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Intermediate", assetName + "_LeftHandThumb2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Distal", assetName + "_LeftHandThumb3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Proximal", assetName + "_RightHandThumb1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Intermediate", assetName + "_RightHandThumb2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Distal", assetName + "_RightHandThumb3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Proximal", assetName + "_LeftHandIndex1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Intermediate", assetName + "_LeftHandIndex2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Distal", assetName + "_LeftHandIndex3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Proximal", assetName + "_RightHandIndex1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Intermediate", assetName + "_RightHandIndex2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Distal", assetName + "_RightHandIndex3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Proximal", assetName + "_LeftHandMiddle1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Intermediate", assetName + "_LeftHandMiddle2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Distal", assetName + "_LeftHandMiddle3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Proximal", assetName + "_RightHandMiddle1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Intermediate", assetName + "_RightHandMiddle2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Distal", assetName + "_RightHandMiddle3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Proximal", assetName + "_LeftHandRing1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Intermediate", assetName + "_LeftHandRing2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Distal", assetName + "_LeftHandRing3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Proximal", assetName + "_RightHandRing1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Intermediate", assetName + "_RightHandRing2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Distal", assetName + "_RightHandRing3" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Proximal", assetName + "_LeftHandPinky1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Intermediate", assetName + "_LeftHandPinky2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Distal", assetName + "_LeftHandPinky3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Proximal", assetName + "_RightHandPinky1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Intermediate", assetName + "_RightHandPinky2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Distal", assetName + "_RightHandPinky3" );
|
|
||||||
break;
|
|
||||||
case OptitrackBoneNameConvention.BVH:
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Hips", assetName + "_Hips" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Spine", assetName + "_Chest" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Chest", assetName + "_Chest2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Neck", assetName + "_Neck" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Head", assetName + "_Head" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftShoulder", assetName + "_LeftCollar" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperArm", assetName + "_LeftShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerArm", assetName + "_LeftElbow" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftHand", assetName + "_LeftWrist" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightShoulder", assetName + "_RightCollar" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperArm", assetName + "_RightShoulder" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerArm", assetName + "_RightElbow" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightHand", assetName + "_RightWrist" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftUpperLeg", assetName + "_LeftHip" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftLowerLeg", assetName + "_LeftKnee" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftFoot", assetName + "_LeftAnkle" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "LeftToeBase", assetName + "_LeftToe" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightUpperLeg", assetName + "_RightHip" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightLowerLeg", assetName + "_RightKnee" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightFoot", assetName + "_RightAnkle" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "RightToeBase", assetName + "_RightToe" );
|
|
||||||
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Proximal", assetName + "_LeftFinger0" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Intermediate", assetName + "_LeftFinger01" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Thumb Distal", assetName + "_LeftFinger02" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Proximal", assetName + "_RightFinger0" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Intermediate", assetName + "_RightFinger01" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Thumb Distal", assetName + "_RightFinger02" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Proximal", assetName + "_LeftFinger1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Intermediate", assetName + "_LeftFinger11" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Index Distal", assetName + "_LeftFinger12" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Proximal", assetName + "_RightFinger1" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Intermediate", assetName + "_RightFinger11" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Index Distal", assetName + "_RightFinger12" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Proximal", assetName + "_LeftFinger2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Intermediate", assetName + "_LeftFinger21" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Middle Distal", assetName + "_LeftFinger22" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Proximal", assetName + "_RightFinger2" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Intermediate", assetName + "_RightFinger21" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Middle Distal", assetName + "_RightFinger22" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Proximal", assetName + "_LeftFinger3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Intermediate", assetName + "_LeftFinger31" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Ring Distal", assetName + "_LeftFinger32" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Proximal", assetName + "_RightFinger3" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Intermediate", assetName + "_RightFinger31" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Ring Distal", assetName + "_RightFinger32" );
|
|
||||||
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Proximal", assetName + "_LeftFinger4" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Intermediate", assetName + "_LeftFinger41" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Left Little Distal", assetName + "_LeftFinger42" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Proximal", assetName + "_RightFinger4" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Intermediate", assetName + "_RightFinger41" );
|
|
||||||
m_cachedMecanimBoneNameMap.Add( "Right Little Distal", assetName + "_RightFinger42" );
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion Private methods
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 6226842a13d56fc4b9f6ffc41f7ef0ec
|
|
||||||
timeCreated: 1467839953
|
|
||||||
licenseType: Free
|
|
||||||
MonoImporter:
|
|
||||||
serializedVersion: 2
|
|
||||||
defaultReferences: []
|
|
||||||
executionOrder: 0
|
|
||||||
icon: {instanceID: 0}
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
@ -1,136 +1,164 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
[CustomPropertyDrawer(typeof(StreamingleFacialReceiver.BlendShapeIntensityOverride))]
|
[CustomPropertyDrawer(typeof(StreamingleFacialReceiver.BlendShapeIntensityOverride))]
|
||||||
public class BlendShapeIntensityOverrideDrawer : PropertyDrawer
|
public class BlendShapeIntensityOverrideDrawer : PropertyDrawer
|
||||||
{
|
{
|
||||||
// 카테고리별로 구분된 ARKit BlendShape 이름 (Popup에 구분선 역할)
|
private static readonly string[] ARKitBlendShapeNames = new string[]
|
||||||
private static readonly string[] ARKitBlendShapeNames = new string[]
|
{
|
||||||
{
|
// Eye
|
||||||
// Eye (0-13)
|
"EyeBlinkLeft", "EyeBlinkRight",
|
||||||
"EyeBlinkLeft", "EyeBlinkRight",
|
"EyeLookDownLeft", "EyeLookDownRight",
|
||||||
"EyeLookDownLeft", "EyeLookDownRight",
|
"EyeLookInLeft", "EyeLookInRight",
|
||||||
"EyeLookInLeft", "EyeLookInRight",
|
"EyeLookOutLeft", "EyeLookOutRight",
|
||||||
"EyeLookOutLeft", "EyeLookOutRight",
|
"EyeLookUpLeft", "EyeLookUpRight",
|
||||||
"EyeLookUpLeft", "EyeLookUpRight",
|
"EyeSquintLeft", "EyeSquintRight",
|
||||||
"EyeSquintLeft", "EyeSquintRight",
|
"EyeWideLeft", "EyeWideRight",
|
||||||
"EyeWideLeft", "EyeWideRight",
|
// Jaw
|
||||||
// Jaw (14-17)
|
"JawForward", "JawLeft", "JawRight", "JawOpen",
|
||||||
"JawForward", "JawLeft", "JawRight", "JawOpen",
|
// Mouth
|
||||||
// Mouth (18-37)
|
"MouthClose", "MouthFunnel", "MouthPucker",
|
||||||
"MouthClose", "MouthFunnel", "MouthPucker",
|
"MouthLeft", "MouthRight",
|
||||||
"MouthLeft", "MouthRight",
|
"MouthSmileLeft", "MouthSmileRight",
|
||||||
"MouthSmileLeft", "MouthSmileRight",
|
"MouthFrownLeft", "MouthFrownRight",
|
||||||
"MouthFrownLeft", "MouthFrownRight",
|
"MouthDimpleLeft", "MouthDimpleRight",
|
||||||
"MouthDimpleLeft", "MouthDimpleRight",
|
"MouthStretchLeft", "MouthStretchRight",
|
||||||
"MouthStretchLeft", "MouthStretchRight",
|
"MouthRollLower", "MouthRollUpper",
|
||||||
"MouthRollLower", "MouthRollUpper",
|
"MouthShrugLower", "MouthShrugUpper",
|
||||||
"MouthShrugLower", "MouthShrugUpper",
|
"MouthPressLeft", "MouthPressRight",
|
||||||
"MouthPressLeft", "MouthPressRight",
|
"MouthLowerDownLeft", "MouthLowerDownRight",
|
||||||
"MouthLowerDownLeft", "MouthLowerDownRight",
|
"MouthUpperUpLeft", "MouthUpperUpRight",
|
||||||
"MouthUpperUpLeft", "MouthUpperUpRight",
|
// Brow
|
||||||
// Brow (38-42)
|
"BrowDownLeft", "BrowDownRight",
|
||||||
"BrowDownLeft", "BrowDownRight",
|
"BrowInnerUp",
|
||||||
"BrowInnerUp",
|
"BrowOuterUpLeft", "BrowOuterUpRight",
|
||||||
"BrowOuterUpLeft", "BrowOuterUpRight",
|
// Cheek/Nose
|
||||||
// Cheek/Nose (43-47)
|
"CheekPuff", "CheekSquintLeft", "CheekSquintRight",
|
||||||
"CheekPuff", "CheekSquintLeft", "CheekSquintRight",
|
"NoseSneerLeft", "NoseSneerRight",
|
||||||
"NoseSneerLeft", "NoseSneerRight",
|
// Tongue
|
||||||
// Tongue (48)
|
"TongueOut",
|
||||||
"TongueOut",
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// 카테고리 구분 표시용
|
private static readonly List<string> DisplayNames;
|
||||||
private static readonly string[] DisplayNames;
|
private static readonly Dictionary<string, int> NameToIndex;
|
||||||
private static readonly Dictionary<string, int> nameToIndex;
|
|
||||||
|
|
||||||
static BlendShapeIntensityOverrideDrawer()
|
static BlendShapeIntensityOverrideDrawer()
|
||||||
{
|
{
|
||||||
nameToIndex = new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
NameToIndex = new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
DisplayNames = new List<string>(ARKitBlendShapeNames.Length);
|
||||||
|
|
||||||
// 카테고리 프리픽스 부여
|
for (int i = 0; i < ARKitBlendShapeNames.Length; i++)
|
||||||
DisplayNames = new string[ARKitBlendShapeNames.Length];
|
{
|
||||||
for (int i = 0; i < ARKitBlendShapeNames.Length; i++)
|
string name = ARKitBlendShapeNames[i];
|
||||||
{
|
string category;
|
||||||
string name = ARKitBlendShapeNames[i];
|
|
||||||
string category;
|
|
||||||
|
|
||||||
if (name.StartsWith("Eye")) category = "Eye";
|
if (name.StartsWith("Eye")) category = "Eye";
|
||||||
else if (name.StartsWith("Jaw")) category = "Jaw";
|
else if (name.StartsWith("Jaw")) category = "Jaw";
|
||||||
else if (name.StartsWith("Mouth")) category = "Mouth";
|
else if (name.StartsWith("Mouth")) category = "Mouth";
|
||||||
else if (name.StartsWith("Brow")) category = "Brow";
|
else if (name.StartsWith("Brow")) category = "Brow";
|
||||||
else if (name.StartsWith("Cheek") || name.StartsWith("Nose")) category = "Cheek-Nose";
|
else if (name.StartsWith("Cheek") || name.StartsWith("Nose")) category = "Cheek-Nose";
|
||||||
else if (name.StartsWith("Tongue")) category = "Tongue";
|
else if (name.StartsWith("Tongue")) category = "Tongue";
|
||||||
else category = "";
|
else category = "";
|
||||||
|
|
||||||
DisplayNames[i] = category + "/" + name;
|
DisplayNames.Add(category + "/" + name);
|
||||||
nameToIndex[name] = i;
|
NameToIndex[name] = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
private const string UssPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss";
|
||||||
{
|
|
||||||
return EditorGUIUtility.singleLineHeight + 2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
public override VisualElement CreatePropertyGUI(SerializedProperty property)
|
||||||
{
|
{
|
||||||
EditorGUI.BeginProperty(position, label, property);
|
var row = new VisualElement();
|
||||||
|
row.AddToClassList("blendshape-override-row");
|
||||||
|
|
||||||
position.y += 1f;
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
position.height -= 2f;
|
if (uss != null) row.styleSheets.Add(uss);
|
||||||
|
|
||||||
var nameProp = property.FindPropertyRelative("blendShapeName");
|
var nameProp = property.FindPropertyRelative("blendShapeName");
|
||||||
var intensityProp = property.FindPropertyRelative("intensity");
|
var intensityProp = property.FindPropertyRelative("intensity");
|
||||||
|
|
||||||
// 새로 추가된 항목의 기본값 보정
|
// Default value for new entries
|
||||||
if (intensityProp.floatValue == 0f && string.IsNullOrEmpty(nameProp.stringValue))
|
if (intensityProp.floatValue == 0f && string.IsNullOrEmpty(nameProp.stringValue))
|
||||||
{
|
{
|
||||||
intensityProp.floatValue = 1.0f;
|
intensityProp.floatValue = 1.0f;
|
||||||
}
|
nameProp.stringValue = ARKitBlendShapeNames[0];
|
||||||
|
property.serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||||
|
}
|
||||||
|
|
||||||
// 레이아웃: [드롭다운 55%] [슬라이더 35%] [값 라벨 10%]
|
// Dropdown for ARKit blendshape selection
|
||||||
float dropW = position.width * 0.55f;
|
int currentIndex = 0;
|
||||||
float sliderW = position.width * 0.35f;
|
if (!string.IsNullOrEmpty(nameProp.stringValue) && NameToIndex.TryGetValue(nameProp.stringValue, out int idx))
|
||||||
float valW = position.width * 0.10f - 6f;
|
currentIndex = idx;
|
||||||
|
|
||||||
Rect dropRect = new Rect(position.x, position.y, dropW - 2f, position.height);
|
var dropdown = new PopupField<string>(DisplayNames, currentIndex);
|
||||||
Rect sliderRect = new Rect(position.x + dropW + 2f, position.y, sliderW - 2f, position.height);
|
dropdown.AddToClassList("blendshape-override-dropdown");
|
||||||
Rect valRect = new Rect(position.x + dropW + sliderW + 4f, position.y, valW, position.height);
|
dropdown.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
int newIdx = DisplayNames.IndexOf(evt.newValue);
|
||||||
|
if (newIdx >= 0 && newIdx < ARKitBlendShapeNames.Length)
|
||||||
|
{
|
||||||
|
nameProp.stringValue = ARKitBlendShapeNames[newIdx];
|
||||||
|
nameProp.serializedObject.ApplyModifiedProperties();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.Add(dropdown);
|
||||||
|
|
||||||
// 현재 인덱스
|
// Slider for intensity
|
||||||
int currentIndex = 0;
|
var slider = new Slider(0f, 3f);
|
||||||
if (!string.IsNullOrEmpty(nameProp.stringValue) && nameToIndex.TryGetValue(nameProp.stringValue, out int idx))
|
slider.value = intensityProp.floatValue;
|
||||||
{
|
slider.AddToClassList("blendshape-override-slider");
|
||||||
currentIndex = idx;
|
row.Add(slider);
|
||||||
}
|
|
||||||
|
|
||||||
// 드롭다운 (카테고리 구분)
|
// Value label with color coding
|
||||||
int newIndex = EditorGUI.Popup(dropRect, currentIndex, DisplayNames);
|
var valueLabel = new Label($"x{intensityProp.floatValue:F1}");
|
||||||
if (newIndex != currentIndex || string.IsNullOrEmpty(nameProp.stringValue))
|
valueLabel.AddToClassList("blendshape-override-value");
|
||||||
{
|
UpdateValueLabelStyle(valueLabel, intensityProp.floatValue);
|
||||||
nameProp.stringValue = ARKitBlendShapeNames[newIndex];
|
row.Add(valueLabel);
|
||||||
}
|
|
||||||
|
|
||||||
// 슬라이더
|
// Bind slider <-> property
|
||||||
intensityProp.floatValue = GUI.HorizontalSlider(sliderRect, intensityProp.floatValue, 0f, 3f);
|
slider.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
intensityProp.floatValue = evt.newValue;
|
||||||
|
intensityProp.serializedObject.ApplyModifiedProperties();
|
||||||
|
valueLabel.text = $"x{evt.newValue:F1}";
|
||||||
|
UpdateValueLabelStyle(valueLabel, evt.newValue);
|
||||||
|
});
|
||||||
|
|
||||||
// 값 표시 (색상으로 강약 표현)
|
// Track external changes to intensity
|
||||||
float val = intensityProp.floatValue;
|
row.TrackPropertyValue(intensityProp, prop =>
|
||||||
Color valColor;
|
{
|
||||||
if (val < 0.5f) valColor = new Color(1f, 0.4f, 0.4f); // 약함 = 빨강
|
slider.SetValueWithoutNotify(prop.floatValue);
|
||||||
else if (val > 1.5f) valColor = new Color(0.4f, 0.8f, 1f); // 강함 = 파랑
|
valueLabel.text = $"x{prop.floatValue:F1}";
|
||||||
else valColor = new Color(0.7f, 0.7f, 0.7f); // 보통 = 회색
|
UpdateValueLabelStyle(valueLabel, prop.floatValue);
|
||||||
|
});
|
||||||
|
|
||||||
var valStyle = new GUIStyle(EditorStyles.miniLabel)
|
// Track external changes to blendShapeName
|
||||||
{
|
row.TrackPropertyValue(nameProp, prop =>
|
||||||
alignment = TextAnchor.MiddleRight,
|
{
|
||||||
fontStyle = FontStyle.Bold,
|
if (!string.IsNullOrEmpty(prop.stringValue) && NameToIndex.TryGetValue(prop.stringValue, out int newIdx))
|
||||||
};
|
{
|
||||||
valStyle.normal.textColor = valColor;
|
dropdown.SetValueWithoutNotify(DisplayNames[newIdx]);
|
||||||
EditorGUI.LabelField(valRect, $"x{val:F1}", valStyle);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
EditorGUI.EndProperty();
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateValueLabelStyle(Label label, float value)
|
||||||
|
{
|
||||||
|
label.RemoveFromClassList("blendshape-value--low");
|
||||||
|
label.RemoveFromClassList("blendshape-value--normal");
|
||||||
|
label.RemoveFromClassList("blendshape-value--high");
|
||||||
|
|
||||||
|
if (value < 0.5f)
|
||||||
|
label.AddToClassList("blendshape-value--low");
|
||||||
|
else if (value > 1.5f)
|
||||||
|
label.AddToClassList("blendshape-value--high");
|
||||||
|
else
|
||||||
|
label.AddToClassList("blendshape-value--normal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,263 +1,188 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
[CustomEditor(typeof(StreamingleFacialReceiver))]
|
[CustomEditor(typeof(StreamingleFacialReceiver))]
|
||||||
public class StreamingleFacialReceiverEditor : Editor
|
public class StreamingleFacialReceiverEditor : Editor
|
||||||
{
|
{
|
||||||
private SerializedProperty mirrorMode;
|
private const string UxmlPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml";
|
||||||
private SerializedProperty faceMeshRenderers;
|
private const string UssPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss";
|
||||||
private SerializedProperty availablePorts;
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
private SerializedProperty enableFiltering;
|
private StreamingleFacialReceiver receiver;
|
||||||
private SerializedProperty smoothingFactor;
|
private VisualElement portButtonsContainer;
|
||||||
private SerializedProperty maxBlendShapeDelta;
|
private Label activePortValue;
|
||||||
private SerializedProperty maxRotationDelta;
|
private VisualElement statusContainer;
|
||||||
private SerializedProperty fastBlendShapeMultiplier;
|
private VisualElement filteringFields;
|
||||||
private SerializedProperty spikeToleranceFrames;
|
|
||||||
private SerializedProperty globalIntensity;
|
|
||||||
private SerializedProperty blendShapeIntensityOverrides;
|
|
||||||
|
|
||||||
private bool showConnection = false;
|
public override VisualElement CreateInspectorGUI()
|
||||||
private bool showFiltering = false;
|
{
|
||||||
private bool showIntensity = false;
|
receiver = (StreamingleFacialReceiver)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
private static readonly Color HeaderColor = new Color(0.18f, 0.18f, 0.18f, 1f);
|
// Load stylesheets
|
||||||
private static readonly Color ActivePortColor = new Color(0.2f, 0.8f, 0.4f, 1f);
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
private static readonly Color InactivePortColor = new Color(0.35f, 0.35f, 0.35f, 1f);
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
private static readonly Color AccentColor = new Color(0.4f, 0.7f, 1f, 1f);
|
|
||||||
|
|
||||||
private GUIStyle _headerStyle;
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
private GUIStyle _sectionBoxStyle;
|
if (uss != null) root.styleSheets.Add(uss);
|
||||||
private GUIStyle _portButtonStyle;
|
|
||||||
private GUIStyle _portButtonActiveStyle;
|
|
||||||
private GUIStyle _statusLabelStyle;
|
|
||||||
|
|
||||||
void OnEnable()
|
// Load UXML
|
||||||
{
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
mirrorMode = serializedObject.FindProperty("mirrorMode");
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
faceMeshRenderers = serializedObject.FindProperty("faceMeshRenderers");
|
|
||||||
availablePorts = serializedObject.FindProperty("availablePorts");
|
|
||||||
enableFiltering = serializedObject.FindProperty("enableFiltering");
|
|
||||||
smoothingFactor = serializedObject.FindProperty("smoothingFactor");
|
|
||||||
maxBlendShapeDelta = serializedObject.FindProperty("maxBlendShapeDelta");
|
|
||||||
maxRotationDelta = serializedObject.FindProperty("maxRotationDelta");
|
|
||||||
fastBlendShapeMultiplier = serializedObject.FindProperty("fastBlendShapeMultiplier");
|
|
||||||
spikeToleranceFrames = serializedObject.FindProperty("spikeToleranceFrames");
|
|
||||||
globalIntensity = serializedObject.FindProperty("globalIntensity");
|
|
||||||
blendShapeIntensityOverrides = serializedObject.FindProperty("blendShapeIntensityOverrides");
|
|
||||||
}
|
|
||||||
|
|
||||||
void InitStyles()
|
// Cache references
|
||||||
{
|
statusContainer = root.Q("statusContainer");
|
||||||
if (_headerStyle != null) return;
|
activePortValue = root.Q<Label>("activePortValue");
|
||||||
|
portButtonsContainer = root.Q("portButtonsContainer");
|
||||||
|
filteringFields = root.Q("filteringFields");
|
||||||
|
|
||||||
_headerStyle = new GUIStyle(EditorStyles.boldLabel)
|
// Auto-find button
|
||||||
{
|
var autoFindBtn = root.Q<Button>("autoFindBtn");
|
||||||
fontSize = 13,
|
var autoFindResult = root.Q<Label>("autoFindResult");
|
||||||
alignment = TextAnchor.MiddleLeft,
|
if (autoFindBtn != null)
|
||||||
padding = new RectOffset(8, 0, 4, 4),
|
autoFindBtn.clicked += () => AutoFindARKitMeshes(autoFindResult);
|
||||||
};
|
|
||||||
_headerStyle.normal.textColor = AccentColor;
|
|
||||||
|
|
||||||
_sectionBoxStyle = new GUIStyle("HelpBox")
|
// Build dynamic port buttons
|
||||||
{
|
RebuildPortButtons();
|
||||||
padding = new RectOffset(10, 10, 8, 8),
|
|
||||||
margin = new RectOffset(0, 0, 4, 8),
|
|
||||||
};
|
|
||||||
|
|
||||||
_portButtonStyle = new GUIStyle(GUI.skin.button)
|
// Track enableFiltering for conditional visibility
|
||||||
{
|
var enableFilteringProp = serializedObject.FindProperty("enableFiltering");
|
||||||
fontSize = 12,
|
UpdateFilteringVisibility(enableFilteringProp.boolValue);
|
||||||
fontStyle = FontStyle.Bold,
|
|
||||||
fixedHeight = 36,
|
|
||||||
alignment = TextAnchor.MiddleCenter,
|
|
||||||
};
|
|
||||||
|
|
||||||
_portButtonActiveStyle = new GUIStyle(_portButtonStyle);
|
root.TrackPropertyValue(enableFilteringProp, prop =>
|
||||||
_portButtonActiveStyle.normal.textColor = Color.white;
|
{
|
||||||
|
UpdateFilteringVisibility(prop.boolValue);
|
||||||
|
});
|
||||||
|
|
||||||
_statusLabelStyle = new GUIStyle(EditorStyles.miniLabel)
|
// Track availablePorts and activePortIndex changes to rebuild port buttons
|
||||||
{
|
var portsProp = serializedObject.FindProperty("availablePorts");
|
||||||
alignment = TextAnchor.MiddleCenter,
|
root.TrackPropertyValue(portsProp, _ => RebuildPortButtons());
|
||||||
fontSize = 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnInspectorGUI()
|
var activeIndexProp = serializedObject.FindProperty("activePortIndex");
|
||||||
{
|
root.TrackPropertyValue(activeIndexProp, _ => RebuildPortButtons());
|
||||||
serializedObject.Update();
|
|
||||||
InitStyles();
|
|
||||||
|
|
||||||
var mocap = (StreamingleFacialReceiver)target;
|
// Play mode status polling
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(200);
|
||||||
|
|
||||||
// 타이틀
|
return root;
|
||||||
EditorGUILayout.Space(4);
|
}
|
||||||
DrawTitle();
|
|
||||||
EditorGUILayout.Space(4);
|
|
||||||
|
|
||||||
// 기본 설정
|
private void UpdateFilteringVisibility(bool enabled)
|
||||||
DrawBasicSettings();
|
{
|
||||||
|
if (filteringFields == null) return;
|
||||||
|
filteringFields.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
// 포트 핫스왑
|
private void RebuildPortButtons()
|
||||||
DrawPortSection(mocap);
|
{
|
||||||
|
if (portButtonsContainer == null || receiver == null) return;
|
||||||
|
portButtonsContainer.Clear();
|
||||||
|
|
||||||
// 필터링
|
// Update active port label
|
||||||
DrawFilteringSection();
|
if (activePortValue != null)
|
||||||
|
activePortValue.text = receiver.LOCAL_PORT.ToString();
|
||||||
|
|
||||||
// 페이셜 강도
|
if (receiver.availablePorts == null || receiver.availablePorts.Length == 0) return;
|
||||||
DrawIntensitySection();
|
|
||||||
|
|
||||||
serializedObject.ApplyModifiedProperties();
|
for (int i = 0; i < receiver.availablePorts.Length; i++)
|
||||||
}
|
{
|
||||||
|
int idx = i;
|
||||||
|
var btn = new Button(() => OnPortButtonClicked(idx))
|
||||||
|
{
|
||||||
|
text = receiver.availablePorts[i].ToString()
|
||||||
|
};
|
||||||
|
btn.AddToClassList("facial-port-btn");
|
||||||
|
|
||||||
void DrawTitle()
|
if (i == receiver.activePortIndex)
|
||||||
{
|
btn.AddToClassList("facial-port-btn--active");
|
||||||
var rect = EditorGUILayout.GetControlRect(false, 32);
|
|
||||||
EditorGUI.DrawRect(rect, HeaderColor);
|
|
||||||
|
|
||||||
var titleStyle = new GUIStyle(EditorStyles.boldLabel)
|
portButtonsContainer.Add(btn);
|
||||||
{
|
}
|
||||||
fontSize = 15,
|
}
|
||||||
alignment = TextAnchor.MiddleLeft,
|
|
||||||
};
|
|
||||||
titleStyle.normal.textColor = Color.white;
|
|
||||||
|
|
||||||
EditorGUI.LabelField(rect, " Streamingle Facial Receiver", titleStyle);
|
private void OnPortButtonClicked(int index)
|
||||||
|
{
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
receiver.SwitchToPort(index);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Switch Port");
|
||||||
|
receiver.activePortIndex = index;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
}
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildPortButtons();
|
||||||
|
}
|
||||||
|
|
||||||
// 상태 표시
|
// ARKit 블렌드셰이프 이름 (감지용 최소 세트)
|
||||||
if (Application.isPlaying)
|
private static readonly HashSet<string> ARKitNames = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
var statusRect = new Rect(rect.xMax - 80, rect.y, 75, rect.height);
|
"EyeBlinkLeft", "EyeBlinkRight", "JawOpen", "MouthClose",
|
||||||
var dotRect = new Rect(statusRect.x - 12, rect.y + rect.height / 2 - 4, 8, 8);
|
"MouthSmileLeft", "MouthSmileRight", "BrowDownLeft", "BrowDownRight",
|
||||||
EditorGUI.DrawRect(dotRect, ActivePortColor);
|
"EyeWideLeft", "EyeWideRight", "MouthFunnel", "MouthPucker",
|
||||||
EditorGUI.LabelField(statusRect, "LIVE", _statusLabelStyle);
|
"CheekPuff", "TongueOut", "NoseSneerLeft", "NoseSneerRight",
|
||||||
}
|
// _L/_R 변형
|
||||||
}
|
"eyeBlink_L", "eyeBlink_R", "jawOpen", "mouthClose",
|
||||||
|
"mouthSmile_L", "mouthSmile_R", "browDown_L", "browDown_R",
|
||||||
|
};
|
||||||
|
|
||||||
void DrawBasicSettings()
|
private const int MinARKitMatchCount = 5;
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(_sectionBoxStyle);
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(faceMeshRenderers, new GUIContent("Face Mesh Renderers"));
|
private void AutoFindARKitMeshes(Label resultLabel)
|
||||||
EditorGUILayout.Space(2);
|
{
|
||||||
EditorGUILayout.PropertyField(mirrorMode, new GUIContent("Mirror Mode (L/R Flip)"));
|
if (receiver == null) return;
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
var allSMRs = receiver.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||||
}
|
var found = new List<SkinnedMeshRenderer>();
|
||||||
|
|
||||||
void DrawPortSection(StreamingleFacialReceiver mocap)
|
foreach (var smr in allSMRs)
|
||||||
{
|
{
|
||||||
showConnection = DrawSectionHeader("Port Hot-Swap", showConnection);
|
if (smr == null || smr.sharedMesh == null) continue;
|
||||||
if (!showConnection) return;
|
|
||||||
|
|
||||||
EditorGUILayout.BeginVertical(_sectionBoxStyle);
|
int matchCount = 0;
|
||||||
|
for (int i = 0; i < smr.sharedMesh.blendShapeCount; i++)
|
||||||
|
{
|
||||||
|
string name = smr.sharedMesh.GetBlendShapeName(i);
|
||||||
|
if (ARKitNames.Contains(name))
|
||||||
|
{
|
||||||
|
matchCount++;
|
||||||
|
if (matchCount >= MinARKitMatchCount) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 포트 표시
|
if (matchCount >= MinARKitMatchCount)
|
||||||
EditorGUILayout.BeginHorizontal();
|
found.Add(smr);
|
||||||
EditorGUILayout.LabelField("Active Port", EditorStyles.miniLabel, GUILayout.Width(70));
|
}
|
||||||
var portLabel = new GUIStyle(EditorStyles.boldLabel);
|
|
||||||
portLabel.normal.textColor = ActivePortColor;
|
|
||||||
EditorGUILayout.LabelField(mocap.LOCAL_PORT.ToString(), portLabel);
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
EditorGUILayout.Space(4);
|
if (found.Count == 0)
|
||||||
|
{
|
||||||
|
if (resultLabel != null)
|
||||||
|
resultLabel.text = "ARKit 블렌드셰이프를 가진 메쉬를 찾지 못했습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 포트 버튼 그리드
|
Undo.RecordObject(target, "Auto Find ARKit Meshes");
|
||||||
if (mocap.availablePorts != null && mocap.availablePorts.Length > 0)
|
receiver.faceMeshRenderers = found.ToArray();
|
||||||
{
|
EditorUtility.SetDirty(target);
|
||||||
EditorGUILayout.BeginHorizontal();
|
serializedObject.Update();
|
||||||
for (int i = 0; i < mocap.availablePorts.Length; i++)
|
|
||||||
{
|
|
||||||
bool isActive = i == mocap.activePortIndex;
|
|
||||||
|
|
||||||
var prevBg = GUI.backgroundColor;
|
if (resultLabel != null)
|
||||||
GUI.backgroundColor = isActive ? ActivePortColor : InactivePortColor;
|
resultLabel.text = $"{found.Count}개 메쉬 등록 완료";
|
||||||
|
}
|
||||||
|
|
||||||
var style = isActive ? _portButtonActiveStyle : _portButtonStyle;
|
private void UpdatePlayModeState()
|
||||||
string label = mocap.availablePorts[i].ToString();
|
{
|
||||||
|
if (statusContainer == null) return;
|
||||||
|
|
||||||
if (GUILayout.Button(label, style))
|
bool isPlaying = Application.isPlaying;
|
||||||
{
|
if (isPlaying && !statusContainer.ClassListContains("facial-status-container--visible"))
|
||||||
if (Application.isPlaying)
|
statusContainer.AddToClassList("facial-status-container--visible");
|
||||||
{
|
else if (!isPlaying && statusContainer.ClassListContains("facial-status-container--visible"))
|
||||||
mocap.SwitchToPort(i);
|
statusContainer.RemoveFromClassList("facial-status-container--visible");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
Undo.RecordObject(mocap, "Switch Port");
|
|
||||||
mocap.activePortIndex = i;
|
|
||||||
EditorUtility.SetDirty(mocap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI.backgroundColor = prevBg;
|
|
||||||
}
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.Space(2);
|
|
||||||
EditorGUILayout.PropertyField(availablePorts, new GUIContent("Port List"), true);
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
void DrawFilteringSection()
|
|
||||||
{
|
|
||||||
showFiltering = DrawSectionHeader("Data Filtering", showFiltering);
|
|
||||||
if (!showFiltering) return;
|
|
||||||
|
|
||||||
EditorGUILayout.BeginVertical(_sectionBoxStyle);
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(enableFiltering, new GUIContent("Enable"));
|
|
||||||
|
|
||||||
if (enableFiltering.boolValue)
|
|
||||||
{
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
EditorGUILayout.Space(2);
|
|
||||||
EditorGUILayout.PropertyField(smoothingFactor, new GUIContent("Smoothing"));
|
|
||||||
EditorGUILayout.PropertyField(maxBlendShapeDelta, new GUIContent("Max BlendShape Delta"));
|
|
||||||
EditorGUILayout.PropertyField(maxRotationDelta, new GUIContent("Max Rotation Delta"));
|
|
||||||
EditorGUILayout.PropertyField(fastBlendShapeMultiplier, new GUIContent("Fast BS Multiplier"));
|
|
||||||
EditorGUILayout.PropertyField(spikeToleranceFrames, new GUIContent("Spike Tolerance"));
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
void DrawIntensitySection()
|
|
||||||
{
|
|
||||||
showIntensity = DrawSectionHeader("Facial Intensity", showIntensity);
|
|
||||||
if (!showIntensity) return;
|
|
||||||
|
|
||||||
EditorGUILayout.BeginVertical(_sectionBoxStyle);
|
|
||||||
|
|
||||||
// Global intensity - 크게 표시
|
|
||||||
EditorGUILayout.LabelField("Global", EditorStyles.miniLabel);
|
|
||||||
EditorGUILayout.PropertyField(globalIntensity, GUIContent.none);
|
|
||||||
|
|
||||||
EditorGUILayout.Space(6);
|
|
||||||
|
|
||||||
// 구분선
|
|
||||||
var lineRect = EditorGUILayout.GetControlRect(false, 1);
|
|
||||||
EditorGUI.DrawRect(lineRect, new Color(0.4f, 0.4f, 0.4f, 0.5f));
|
|
||||||
|
|
||||||
EditorGUILayout.Space(4);
|
|
||||||
EditorGUILayout.LabelField("Per-BlendShape Overrides", EditorStyles.miniLabel);
|
|
||||||
EditorGUILayout.PropertyField(blendShapeIntensityOverrides, GUIContent.none, true);
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool DrawSectionHeader(string title, bool expanded)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(2);
|
|
||||||
var rect = EditorGUILayout.GetControlRect(false, 24);
|
|
||||||
EditorGUI.DrawRect(rect, HeaderColor);
|
|
||||||
|
|
||||||
// 폴드 화살표 + 타이틀
|
|
||||||
var foldRect = new Rect(rect.x + 4, rect.y, rect.width - 4, rect.height);
|
|
||||||
bool result = EditorGUI.Foldout(foldRect, expanded, " " + title, true, _headerStyle);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: f26ead3daa5f7de479c0c8547b3a792d
|
guid: 22c3df2257c238b459de9d41c03bff11
|
||||||
TextScriptImporter:
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
assetBundleName:
|
assetBundleName:
|
||||||
187
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss
vendored
Normal file
187
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss
vendored
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
/* Streamingle Facial Receiver Editor Styles */
|
||||||
|
|
||||||
|
/* ---- Title Bar ---- */
|
||||||
|
|
||||||
|
.facial-title-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-title-text {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-status-container {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-status-container--visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #22c55e;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-status-label {
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Auto Find ---- */
|
||||||
|
|
||||||
|
.facial-auto-find-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-auto-find-btn {
|
||||||
|
background-color: rgba(99, 102, 241, 0.25);
|
||||||
|
color: #a5b4fc;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-auto-find-btn:hover {
|
||||||
|
background-color: rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-auto-find-result {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Port Hot-Swap ---- */
|
||||||
|
|
||||||
|
.facial-active-port-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-value {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-buttons {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-btn {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-btn--active {
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facial-port-btn--active:hover {
|
||||||
|
background-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Data Filtering ---- */
|
||||||
|
|
||||||
|
#filteringFields {
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filteringFields--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Facial Intensity ---- */
|
||||||
|
|
||||||
|
.facial-separator {
|
||||||
|
height: 1px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- BlendShape Override Drawer ---- */
|
||||||
|
|
||||||
|
.blendshape-override-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-override-dropdown {
|
||||||
|
flex-grow: 55;
|
||||||
|
flex-basis: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-override-slider {
|
||||||
|
flex-grow: 35;
|
||||||
|
flex-basis: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-override-value {
|
||||||
|
flex-grow: 10;
|
||||||
|
flex-basis: 0;
|
||||||
|
-unity-text-align: middle-right;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-value--low {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-value--normal {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blendshape-value--high {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
12
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss.meta
vendored
Normal file
12
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss.meta
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d871c0021a698af429e7f7c79b200c71
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
59
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml
vendored
Normal file
59
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- Title Bar -->
|
||||||
|
<ui:VisualElement name="titleBar" class="facial-title-bar">
|
||||||
|
<ui:Label text="Streamingle Facial Receiver" class="facial-title-text"/>
|
||||||
|
<ui:VisualElement name="statusContainer" class="facial-status-container">
|
||||||
|
<ui:VisualElement name="statusDot" class="facial-status-dot"/>
|
||||||
|
<ui:Label name="statusLabel" text="LIVE" class="facial-status-label"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Basic Settings -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Basic Settings" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="faceMeshRenderers" label="Face Mesh Renderers"/>
|
||||||
|
<ui:VisualElement name="autoFindRow" class="facial-auto-find-row">
|
||||||
|
<ui:Button name="autoFindBtn" text="Auto Find ARKit Meshes" class="facial-auto-find-btn"/>
|
||||||
|
<ui:Label name="autoFindResult" class="facial-auto-find-result"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<uie:PropertyField binding-path="mirrorMode" label="Mirror Mode (L/R Flip)"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Port Hot-Swap (port buttons built dynamically in C#) -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Port Hot-Swap" value="true" class="section-foldout">
|
||||||
|
<ui:VisualElement name="activePortRow" class="facial-active-port-row">
|
||||||
|
<ui:Label text="Active Port" class="facial-port-label"/>
|
||||||
|
<ui:Label name="activePortValue" text="---" class="facial-port-value"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement name="portButtonsContainer" class="facial-port-buttons"/>
|
||||||
|
<uie:PropertyField binding-path="availablePorts" label="Port List"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Data Filtering -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Data Filtering" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="enableFiltering" label="Enable"/>
|
||||||
|
<ui:VisualElement name="filteringFields">
|
||||||
|
<uie:PropertyField binding-path="smoothingFactor" label="Smoothing"/>
|
||||||
|
<uie:PropertyField binding-path="maxBlendShapeDelta" label="Max BlendShape Delta"/>
|
||||||
|
<uie:PropertyField binding-path="maxRotationDelta" label="Max Rotation Delta"/>
|
||||||
|
<uie:PropertyField binding-path="fastBlendShapeMultiplier" label="Fast BS Multiplier"/>
|
||||||
|
<uie:PropertyField binding-path="spikeToleranceFrames" label="Spike Tolerance"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Facial Intensity -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Facial Intensity" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="globalIntensity" label="Global Intensity"/>
|
||||||
|
<ui:VisualElement class="facial-separator"/>
|
||||||
|
<uie:PropertyField binding-path="blendShapeIntensityOverrides" label="Per-BlendShape Overrides"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
10
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml.meta
vendored
Normal file
10
Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml.meta
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 891f2c72647a45a42879bd64a9da5638
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
BIN
Assets/Resources/KindRetargeting/retargeting_script.txt
(Stored with Git LFS)
BIN
Assets/Resources/KindRetargeting/retargeting_script.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/KindRetargeting/retargeting_style.txt
(Stored with Git LFS)
BIN
Assets/Resources/KindRetargeting/retargeting_style.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/KindRetargeting/retargeting_template.txt
(Stored with Git LFS)
BIN
Assets/Resources/KindRetargeting/retargeting_template.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/KindRetargeting/웹 리타겟팅.txt
(Stored with Git LFS)
BIN
Assets/Resources/KindRetargeting/웹 리타겟팅.txt
(Stored with Git LFS)
Binary file not shown.
8
Assets/Resources/StreamingleDashboard.meta
Normal file
8
Assets/Resources/StreamingleDashboard.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 34ccdee11454e6247a25b77c701bc426
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Normal file
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 500a6ad5f33a60347b30b0ca73a3e650
|
guid: 90070596c9064a94885e25321c3db607
|
||||||
TextScriptImporter:
|
TextScriptImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
Normal file
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 227ed0897c5a66b47b6431f0c95fa98a
|
guid: 3cfda294f25f5fa4a8c3d08d4bd8590c
|
||||||
TextScriptImporter:
|
TextScriptImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
BIN
Assets/Resources/StreamingleDashboard/dashboard_template.txt
(Stored with Git LFS)
Normal file
BIN
Assets/Resources/StreamingleDashboard/dashboard_template.txt
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: b9d33120f9b2266498b51080310c89e6
|
guid: c9497b0da91a15c49bb472beff83a9b9
|
||||||
TextScriptImporter:
|
TextScriptImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
8
Assets/Resources/StreamingleUI.meta
Normal file
8
Assets/Resources/StreamingleUI.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7d1e18befc03cb442a30ade3a2a05bfe
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
362
Assets/Resources/StreamingleUI/StreamingleControlPanel.uss
Normal file
362
Assets/Resources/StreamingleUI/StreamingleControlPanel.uss
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/* ======== Streamingle Runtime Control Panel — Left Overlay (150%) ======== */
|
||||||
|
|
||||||
|
/* Font — NanumGothic */
|
||||||
|
.panel-root, .panel-root Label, .panel-root Button {
|
||||||
|
-unity-font: resource('Fonts/NanumGothic');
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title, .cat-title, .cat-btn--active, .action-btn, .status-value {
|
||||||
|
-unity-font: resource('Fonts/NanumGothicBold');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Root — left-docked, full height, ~1/3 screen */
|
||||||
|
.panel-root {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 520px;
|
||||||
|
background-color: rgba(15, 23, 42, 0.96);
|
||||||
|
border-right-width: 1px;
|
||||||
|
border-right-color: rgba(99, 102, 241, 0.25);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header ---- */
|
||||||
|
.header {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 18px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: rgb(99, 102, 241);
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.esc-badge {
|
||||||
|
color: rgb(100, 116, 139);
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab Bar ---- */
|
||||||
|
.tab-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 48px;
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: 0;
|
||||||
|
border-bottom-width: 3px;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
color: rgb(100, 116, 139);
|
||||||
|
font-size: 15px;
|
||||||
|
-unity-font-style: normal;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin: 0;
|
||||||
|
transition-duration: 0.12s;
|
||||||
|
transition-property: color, border-bottom-color, background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn:hover {
|
||||||
|
color: rgb(203, 213, 225);
|
||||||
|
background-color: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn--active {
|
||||||
|
color: rgb(129, 140, 248);
|
||||||
|
border-bottom-color: rgb(99, 102, 241);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
background-color: rgba(99, 102, 241, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Content ---- */
|
||||||
|
.content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 16px 18px 0 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-title {
|
||||||
|
font-size: 20px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: rgb(241, 245, 249);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(100, 116, 139);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Action Items ---- */
|
||||||
|
.action-item {
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-property: background-color, border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item--active {
|
||||||
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
|
background-color: rgba(34, 197, 94, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item-index {
|
||||||
|
color: rgb(71, 85, 105);
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 36px;
|
||||||
|
-unity-text-align: middle-right;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item-label {
|
||||||
|
flex-grow: 1;
|
||||||
|
color: rgb(226, 232, 240);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item-status {
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.action-btn {
|
||||||
|
background-color: rgb(99, 102, 241);
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-width: 0;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
min-width: 80px;
|
||||||
|
height: 34px;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-property: background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background-color: rgb(79, 70, 229);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
background-color: rgb(67, 56, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--secondary {
|
||||||
|
background-color: rgb(51, 65, 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--secondary:hover {
|
||||||
|
background-color: rgb(71, 85, 105);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--danger {
|
||||||
|
background-color: rgb(220, 38, 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--danger:hover {
|
||||||
|
background-color: rgb(185, 28, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--success {
|
||||||
|
background-color: rgb(22, 163, 74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--success:hover {
|
||||||
|
background-color: rgb(21, 128, 61);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action row */
|
||||||
|
.action-row {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row > .action-btn {
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section group title */
|
||||||
|
.group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(100, 116, 139);
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.05);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar group */
|
||||||
|
.avatar-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group-name {
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgb(148, 163, 184);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-row {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-btn {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgb(203, 213, 225);
|
||||||
|
border-radius: 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-property: background-color, border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-btn--active {
|
||||||
|
background-color: rgba(99, 102, 241, 0.2);
|
||||||
|
border-color: rgb(99, 102, 241);
|
||||||
|
color: white;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status Bar ---- */
|
||||||
|
.status-bar {
|
||||||
|
height: 44px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
border-top-width: 1px;
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.06);
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-group {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: rgb(71, 85, 105);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgb(51, 65, 85);
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--on {
|
||||||
|
background-color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--rec {
|
||||||
|
background-color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
color: rgb(203, 213, 225);
|
||||||
|
font-size: 13px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar ---- */
|
||||||
|
.unity-scroller--vertical {
|
||||||
|
width: 6px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unity-scroller--vertical .unity-base-slider__tracker {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unity-scroller--vertical .unity-base-slider__dragger {
|
||||||
|
background-color: rgba(148, 163, 184, 0.25);
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-height: 30px;
|
||||||
|
transition-duration: 0.15s;
|
||||||
|
transition-property: background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unity-scroller--vertical .unity-base-slider__dragger:hover {
|
||||||
|
background-color: rgba(148, 163, 184, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unity-scroller--vertical .unity-base-slider__dragger:active {
|
||||||
|
background-color: rgba(99, 102, 241, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar arrow buttons */
|
||||||
|
.unity-scroller--vertical .unity-repeat-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal scrollbar (hide if any) */
|
||||||
|
.unity-scroller--horizontal {
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9fd212d6704312e45bb42dc283eb7fcc
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
53
Assets/Resources/StreamingleUI/StreamingleControlPanel.uxml
Normal file
53
Assets/Resources/StreamingleUI/StreamingleControlPanel.uxml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements">
|
||||||
|
<Style src="StreamingleControlPanel.uss"/>
|
||||||
|
<ui:VisualElement name="panel-root" class="panel-root">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<ui:VisualElement name="header" class="header">
|
||||||
|
<ui:Label text="STREAMINGLE" class="panel-title"/>
|
||||||
|
<ui:Label text="ESC" class="esc-badge"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<ui:VisualElement name="tab-bar" class="tab-bar">
|
||||||
|
<ui:Button name="btn-camera" text="Camera" class="cat-btn"/>
|
||||||
|
<ui:Button name="btn-item" text="Item" class="cat-btn"/>
|
||||||
|
<ui:Button name="btn-event" text="Event" class="cat-btn"/>
|
||||||
|
<ui:Button name="btn-avatar" text="Avatar" class="cat-btn"/>
|
||||||
|
<ui:Button name="btn-system" text="System" class="cat-btn"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<ui:VisualElement name="content" class="content">
|
||||||
|
<ui:Label name="cat-title" text="" class="cat-title"/>
|
||||||
|
<ui:Label name="cat-desc" text="" class="cat-desc"/>
|
||||||
|
<ui:ScrollView name="action-list" class="action-list"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<ui:VisualElement name="status-bar" class="status-bar">
|
||||||
|
<ui:VisualElement class="status-group">
|
||||||
|
<ui:Label text="WS" class="status-label"/>
|
||||||
|
<ui:VisualElement name="ws-dot" class="status-dot"/>
|
||||||
|
<ui:Label name="ws-value" text="0" class="status-value"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="status-group">
|
||||||
|
<ui:Label text="REC" class="status-label"/>
|
||||||
|
<ui:VisualElement name="rec-dot" class="status-dot"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="status-group">
|
||||||
|
<ui:Label text="OT" class="status-label"/>
|
||||||
|
<ui:VisualElement name="optitrack-dot" class="status-dot"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="status-group">
|
||||||
|
<ui:Label text="FC" class="status-label"/>
|
||||||
|
<ui:Label name="facial-value" text="0" class="status-value"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="status-group">
|
||||||
|
<ui:Label text="RT" class="status-label"/>
|
||||||
|
<ui:VisualElement name="retargeting-dot" class="status-dot"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4fc5c7da37e69a14f901a96cc1ad28ea
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
/* ======== Streamingle Controller Setup Tool ======== */
|
||||||
|
|
||||||
|
/* Root */
|
||||||
|
.root {
|
||||||
|
padding: 8px;
|
||||||
|
-unity-font: resource('Fonts/NanumGothic');
|
||||||
|
}
|
||||||
|
|
||||||
|
.root Label, .root Button, .root Toggle, .root TextField {
|
||||||
|
-unity-font: resource('Fonts/NanumGothic');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section ---- */
|
||||||
|
.section {
|
||||||
|
background-color: rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-font: resource('Fonts/NanumGothicBold');
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status Row ---- */
|
||||||
|
.status-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #334155;
|
||||||
|
margin-right: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--found {
|
||||||
|
background-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--missing {
|
||||||
|
background-color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
min-width: 130px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-location {
|
||||||
|
flex-grow: 1;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-select-btn {
|
||||||
|
background-color: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-select-btn:hover {
|
||||||
|
background-color: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Controller Toggle Row ---- */
|
||||||
|
.controller-toggle-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-toggle-row Toggle {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-exists-badge {
|
||||||
|
background-color: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Parent Settings ---- */
|
||||||
|
.parent-name-field {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Actions ---- */
|
||||||
|
.actions-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-primary {
|
||||||
|
flex-direction: row;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-primary > Button {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-font: resource('Fonts/NanumGothicBold');
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-primary > Button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:disabled {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect {
|
||||||
|
background-color: #0ea5e9;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect:hover {
|
||||||
|
background-color: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect:disabled {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondary {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondary > Button {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondary > Button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondary > Button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One-click setup */
|
||||||
|
.btn-one-click {
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
background-color: #16a34a;
|
||||||
|
color: white;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-font: resource('Fonts/NanumGothicBold');
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click:hover {
|
||||||
|
background-color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-one-click:disabled {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Result Log ---- */
|
||||||
|
.result-log {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-log Label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary line */
|
||||||
|
.summary-line {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-count {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-font: resource('Fonts/NanumGothicBold');
|
||||||
|
font-size: 14px;
|
||||||
|
color: #818cf8;
|
||||||
|
min-width: 24px;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3d7a2cb2b663b5449a2fc0190582217c
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
<Style src="StreamingleControllerSetupTool.uss"/>
|
||||||
|
|
||||||
|
<ui:VisualElement class="root">
|
||||||
|
|
||||||
|
<!-- One-Click Setup -->
|
||||||
|
<ui:Button name="btn-one-click" text="원클릭 설정" class="btn-one-click"
|
||||||
|
tooltip="컨트롤러 탐색 → 미존재 항목 생성 → 자동 연결을 한번에 수행합니다"/>
|
||||||
|
|
||||||
|
<!-- Status Summary -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Label text="씬 상태" class="section-title"/>
|
||||||
|
<ui:VisualElement name="status-list"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Controllers to Create -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Label text="컨트롤러 생성" class="section-title"/>
|
||||||
|
<ui:VisualElement name="create-toggles"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Parent Object Settings -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Label text="부모 오브젝트" class="section-title"/>
|
||||||
|
<ui:Toggle name="toggle-create-parent" label="부모 하위로 정리" value="true"
|
||||||
|
tooltip="생성된 컨트롤러들을 부모 오브젝트 하위에 정리합니다"/>
|
||||||
|
<ui:TextField name="field-parent-name" label="이름" value="Streamingle 컨트롤러들" class="parent-name-field"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Advanced Options -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Label text="고급" class="section-title"/>
|
||||||
|
<ui:Toggle name="toggle-auto-connect" label="StreamDeck 매니저에 자동 연결" value="true"
|
||||||
|
tooltip="생성/탐색된 컨트롤러들을 StreamDeck 매니저에 자동 연결"/>
|
||||||
|
<ui:Toggle name="toggle-move-existing" label="기존 컨트롤러도 부모로 이동" value="true"
|
||||||
|
tooltip="기존에 존재하는 컨트롤러들도 부모 하위로 이동"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<ui:VisualElement class="actions-section">
|
||||||
|
<ui:VisualElement class="actions-primary">
|
||||||
|
<ui:Button name="btn-create" text="선택 항목 생성" class="btn-create"
|
||||||
|
tooltip="체크된 컨트롤러만 생성합니다"/>
|
||||||
|
<ui:Button name="btn-connect" text="전체 연결" class="btn-connect"
|
||||||
|
tooltip="발견된 컨트롤러들을 StreamDeck 매니저에 연결합니다"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="actions-secondary">
|
||||||
|
<ui:Button name="btn-refresh" text="새로고침" tooltip="씬에서 컨트롤러를 다시 탐색합니다"/>
|
||||||
|
<ui:Button name="btn-move" text="부모로 이동" tooltip="기존 컨트롤러들을 부모 오브젝트 하위로 이동"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Result Log -->
|
||||||
|
<ui:ScrollView name="result-log" class="result-log">
|
||||||
|
<ui:Label name="result-text" text="준비됨."/>
|
||||||
|
</ui:ScrollView>
|
||||||
|
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b7e5281735e39194f8573efa503bc0f1
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -7,14 +7,12 @@ namespace KindRetargeting.Editor
|
|||||||
[CustomEditor(typeof(RetargetingRemoteController))]
|
[CustomEditor(typeof(RetargetingRemoteController))]
|
||||||
public class RetargetingRemoteControllerEditor : UnityEditor.Editor
|
public class RetargetingRemoteControllerEditor : UnityEditor.Editor
|
||||||
{
|
{
|
||||||
private SerializedProperty httpPortProp;
|
|
||||||
private SerializedProperty wsPortProp;
|
private SerializedProperty wsPortProp;
|
||||||
private SerializedProperty autoStartProp;
|
private SerializedProperty autoStartProp;
|
||||||
private SerializedProperty registeredCharactersProp;
|
private SerializedProperty registeredCharactersProp;
|
||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
httpPortProp = serializedObject.FindProperty("httpPort");
|
|
||||||
wsPortProp = serializedObject.FindProperty("wsPort");
|
wsPortProp = serializedObject.FindProperty("wsPort");
|
||||||
autoStartProp = serializedObject.FindProperty("autoStart");
|
autoStartProp = serializedObject.FindProperty("autoStart");
|
||||||
registeredCharactersProp = serializedObject.FindProperty("registeredCharacters");
|
registeredCharactersProp = serializedObject.FindProperty("registeredCharacters");
|
||||||
@ -28,7 +26,7 @@ namespace KindRetargeting.Editor
|
|||||||
|
|
||||||
// 헤더
|
// 헤더
|
||||||
EditorGUILayout.Space(5);
|
EditorGUILayout.Space(5);
|
||||||
EditorGUILayout.LabelField("리타게팅 웹 리모컨", EditorStyles.boldLabel);
|
EditorGUILayout.LabelField("리타게팅 리모컨 (WebSocket)", EditorStyles.boldLabel);
|
||||||
EditorGUILayout.Space(5);
|
EditorGUILayout.Space(5);
|
||||||
|
|
||||||
// 상태 표시
|
// 상태 표시
|
||||||
@ -67,44 +65,13 @@ namespace KindRetargeting.Editor
|
|||||||
EditorGUILayout.LabelField("서버 설정", EditorStyles.boldLabel);
|
EditorGUILayout.LabelField("서버 설정", EditorStyles.boldLabel);
|
||||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(httpPortProp, new GUIContent("HTTP 포트"));
|
|
||||||
EditorGUILayout.PropertyField(wsPortProp, new GUIContent("WebSocket 포트"));
|
EditorGUILayout.PropertyField(wsPortProp, new GUIContent("WebSocket 포트"));
|
||||||
EditorGUILayout.PropertyField(autoStartProp, new GUIContent("자동 시작"));
|
EditorGUILayout.PropertyField(autoStartProp, new GUIContent("자동 시작"));
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
EditorGUILayout.EndVertical();
|
||||||
|
|
||||||
EditorGUILayout.Space(10);
|
EditorGUILayout.Space(5);
|
||||||
|
EditorGUILayout.HelpBox("리타게팅 UI는 Streamingle Dashboard에 통합되었습니다.\n대시보드의 Retargeting 탭에서 사용하세요.", MessageType.Info);
|
||||||
// 접속 URL
|
|
||||||
if (Application.isPlaying && controller.IsRunning)
|
|
||||||
{
|
|
||||||
EditorGUILayout.LabelField("접속 URL", EditorStyles.boldLabel);
|
|
||||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
|
||||||
|
|
||||||
string localUrl = $"http://localhost:{httpPortProp.intValue}";
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
|
||||||
EditorGUILayout.TextField("로컬", localUrl);
|
|
||||||
if (GUILayout.Button("복사", GUILayout.Width(40)))
|
|
||||||
{
|
|
||||||
EditorGUIUtility.systemCopyBuffer = localUrl;
|
|
||||||
}
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
string localIP = GetLocalIPAddress();
|
|
||||||
if (!string.IsNullOrEmpty(localIP))
|
|
||||||
{
|
|
||||||
string networkUrl = $"http://{localIP}:{httpPortProp.intValue}";
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
|
||||||
EditorGUILayout.TextField("네트워크", networkUrl);
|
|
||||||
if (GUILayout.Button("복사", GUILayout.Width(40)))
|
|
||||||
{
|
|
||||||
EditorGUIUtility.systemCopyBuffer = networkUrl;
|
|
||||||
}
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.Space(10);
|
EditorGUILayout.Space(10);
|
||||||
|
|
||||||
@ -163,22 +130,5 @@ namespace KindRetargeting.Editor
|
|||||||
serializedObject.ApplyModifiedProperties();
|
serializedObject.ApplyModifiedProperties();
|
||||||
Debug.Log($"[RetargetingRemote] {characters.Length}개의 캐릭터를 찾았습니다.");
|
Debug.Log($"[RetargetingRemote] {characters.Length}개의 캐릭터를 찾았습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetLocalIPAddress()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
|
|
||||||
foreach (var ip in host.AddressList)
|
|
||||||
{
|
|
||||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
||||||
{
|
|
||||||
return ip.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(SimplePoseTransfer))]
|
||||||
|
public class SimplePoseTransferEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/KindRetargeting/Editor/UXML/SimplePoseTransferEditor.uxml";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 882d24d531c184345901e519b629af77
|
||||||
8
Assets/Scripts/KindRetargeting/Editor/UXML.meta
Normal file
8
Assets/Scripts/KindRetargeting/Editor/UXML.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e5cbe9ba9e3b93545b67273bcb17de08
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- 포즈 전송 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="포즈 전송 설정" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="sourceBone" label="소스 Animator"/>
|
||||||
|
<uie:PropertyField binding-path="targetBones" label="타겟 Animator"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 스케일 전송 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="스케일 전송" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="transferHeadScale" label="머리 스케일 전송" tooltip="소스의 머리 스케일을 타겟에도 적용"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1a1732001c5b56a4c902abb5d2613871
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -1,246 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace KindRetargeting.Remote
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP 서버 - 웹 리모컨 UI 페이지를 제공합니다.
|
|
||||||
/// Resources 폴더에서 CSS, HTML 템플릿, JavaScript를 로드합니다.
|
|
||||||
/// </summary>
|
|
||||||
public class RetargetingHTTPServer
|
|
||||||
{
|
|
||||||
private HttpListener listener;
|
|
||||||
private Thread listenerThread;
|
|
||||||
private bool isRunning = false;
|
|
||||||
|
|
||||||
private int httpPort;
|
|
||||||
private int wsPort;
|
|
||||||
|
|
||||||
private string cachedCSS = "";
|
|
||||||
private string cachedTemplate = "";
|
|
||||||
private string cachedJS = "";
|
|
||||||
|
|
||||||
private List<string> boundAddresses = new List<string>();
|
|
||||||
|
|
||||||
public bool IsRunning => isRunning;
|
|
||||||
public IReadOnlyList<string> BoundAddresses => boundAddresses;
|
|
||||||
|
|
||||||
public RetargetingHTTPServer(int httpPort, int wsPort)
|
|
||||||
{
|
|
||||||
this.httpPort = httpPort;
|
|
||||||
this.wsPort = wsPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
if (isRunning) return;
|
|
||||||
|
|
||||||
LoadResources();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
listener = new HttpListener();
|
|
||||||
boundAddresses.Clear();
|
|
||||||
|
|
||||||
// 로컬 접속 지원
|
|
||||||
listener.Prefixes.Add($"http://localhost:{httpPort}/");
|
|
||||||
boundAddresses.Add($"http://localhost:{httpPort}");
|
|
||||||
|
|
||||||
listener.Prefixes.Add($"http://127.0.0.1:{httpPort}/");
|
|
||||||
boundAddresses.Add($"http://127.0.0.1:{httpPort}");
|
|
||||||
|
|
||||||
// 외부 접속도 시도
|
|
||||||
try
|
|
||||||
{
|
|
||||||
listener.Prefixes.Add($"http://+:{httpPort}/");
|
|
||||||
string localIP = GetLocalIPAddress();
|
|
||||||
if (!string.IsNullOrEmpty(localIP))
|
|
||||||
{
|
|
||||||
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
Debug.LogWarning("[RetargetingHTTP] 외부 접속 바인딩 실패. localhost만 사용 가능합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.Start();
|
|
||||||
isRunning = true;
|
|
||||||
|
|
||||||
listenerThread = new Thread(HandleRequests);
|
|
||||||
listenerThread.IsBackground = true;
|
|
||||||
listenerThread.Start();
|
|
||||||
|
|
||||||
Debug.Log("[RetargetingHTTP] HTTP 서버 시작됨");
|
|
||||||
foreach (var addr in boundAddresses)
|
|
||||||
{
|
|
||||||
Debug.Log($"[RetargetingHTTP] 접속: {addr}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.LogError($"[RetargetingHTTP] 서버 시작 실패: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
if (!isRunning) return;
|
|
||||||
|
|
||||||
isRunning = false;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
listener?.Stop();
|
|
||||||
listener?.Close();
|
|
||||||
}
|
|
||||||
catch (Exception) { }
|
|
||||||
|
|
||||||
Debug.Log("[RetargetingHTTP] HTTP 서버 중지됨");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadResources()
|
|
||||||
{
|
|
||||||
TextAsset cssAsset = Resources.Load<TextAsset>("KindRetargeting/retargeting_style");
|
|
||||||
TextAsset templateAsset = Resources.Load<TextAsset>("KindRetargeting/retargeting_template");
|
|
||||||
TextAsset jsAsset = Resources.Load<TextAsset>("KindRetargeting/retargeting_script");
|
|
||||||
|
|
||||||
cachedCSS = cssAsset != null ? cssAsset.text : GetFallbackCSS();
|
|
||||||
cachedTemplate = templateAsset != null ? templateAsset.text : GetFallbackTemplate();
|
|
||||||
cachedJS = jsAsset != null ? jsAsset.text : GetFallbackJS();
|
|
||||||
|
|
||||||
if (cssAsset == null || templateAsset == null || jsAsset == null)
|
|
||||||
{
|
|
||||||
Debug.LogWarning("[RetargetingHTTP] 일부 리소스를 로드할 수 없습니다. Fallback 사용 중.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleRequests()
|
|
||||||
{
|
|
||||||
while (isRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
HttpListenerContext context = listener.GetContext();
|
|
||||||
ProcessRequest(context);
|
|
||||||
}
|
|
||||||
catch (HttpListenerException)
|
|
||||||
{
|
|
||||||
// 서버 종료 시 발생
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (isRunning)
|
|
||||||
{
|
|
||||||
Debug.LogError($"[RetargetingHTTP] 요청 처리 오류: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ProcessRequest(HttpListenerContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string path = context.Request.Url.AbsolutePath;
|
|
||||||
|
|
||||||
string responseContent;
|
|
||||||
string contentType;
|
|
||||||
|
|
||||||
if (path == "/" || path == "/index.html")
|
|
||||||
{
|
|
||||||
responseContent = GenerateHTML();
|
|
||||||
contentType = "text/html; charset=utf-8";
|
|
||||||
}
|
|
||||||
else if (path == "/style.css")
|
|
||||||
{
|
|
||||||
responseContent = cachedCSS;
|
|
||||||
contentType = "text/css; charset=utf-8";
|
|
||||||
}
|
|
||||||
else if (path == "/script.js")
|
|
||||||
{
|
|
||||||
responseContent = cachedJS.Replace("{{WS_PORT}}", wsPort.ToString());
|
|
||||||
contentType = "application/javascript; charset=utf-8";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = 404;
|
|
||||||
responseContent = "Not Found";
|
|
||||||
contentType = "text/plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
|
|
||||||
context.Response.ContentType = contentType;
|
|
||||||
context.Response.ContentLength64 = buffer.Length;
|
|
||||||
context.Response.AddHeader("Cache-Control", "no-cache");
|
|
||||||
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
|
|
||||||
context.Response.Close();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.LogError($"[RetargetingHTTP] 응답 처리 오류: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GenerateHTML()
|
|
||||||
{
|
|
||||||
string html = cachedTemplate;
|
|
||||||
html = html.Replace("{{CSS}}", cachedCSS);
|
|
||||||
html = html.Replace("{{JS}}", cachedJS.Replace("{{WS_PORT}}", wsPort.ToString()));
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetLocalIPAddress()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var host = Dns.GetHostEntry(Dns.GetHostName());
|
|
||||||
foreach (var ip in host.AddressList)
|
|
||||||
{
|
|
||||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
||||||
{
|
|
||||||
return ip.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception) { }
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetFallbackCSS()
|
|
||||||
{
|
|
||||||
return @"
|
|
||||||
body { font-family: sans-serif; background: #1a1a2e; color: #fff; padding: 20px; }
|
|
||||||
.error { color: #ff6b6b; padding: 20px; text-align: center; }
|
|
||||||
";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetFallbackTemplate()
|
|
||||||
{
|
|
||||||
return @"
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset=""UTF-8"">
|
|
||||||
<title>리타게팅 리모컨</title>
|
|
||||||
<style>{{CSS}}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class=""error"">
|
|
||||||
리소스 파일을 찾을 수 없습니다.<br>
|
|
||||||
Assets/Resources/KindRetargeting/ 폴더에 파일이 있는지 확인해주세요.
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetFallbackJS()
|
|
||||||
{
|
|
||||||
return "console.error('JavaScript resource not found');";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: aefa700f1b9823544bf33043b01244b2
|
|
||||||
@ -9,25 +9,23 @@ namespace KindRetargeting.Remote
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 리타게팅 원격 제어 컨트롤러
|
/// 리타게팅 원격 제어 컨트롤러
|
||||||
/// HTTP 서버와 WebSocket 서버를 관리하고 메시지를 처리합니다.
|
/// WebSocket 서버를 관리하고 메시지를 처리합니다.
|
||||||
|
/// (HTTP UI는 Streamingle Dashboard에 통합됨)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RetargetingRemoteController : MonoBehaviour
|
public class RetargetingRemoteController : MonoBehaviour
|
||||||
{
|
{
|
||||||
[Header("서버 설정")]
|
[Header("서버 설정")]
|
||||||
[SerializeField] private int httpPort = 8080;
|
[SerializeField] private int wsPort = 64212;
|
||||||
[SerializeField] private int wsPort = 8081;
|
|
||||||
[SerializeField] private bool autoStart = true;
|
[SerializeField] private bool autoStart = true;
|
||||||
|
|
||||||
[Header("캐릭터 등록")]
|
[Header("캐릭터 등록")]
|
||||||
[SerializeField] private List<CustomRetargetingScript> registeredCharacters = new List<CustomRetargetingScript>();
|
[SerializeField] private List<CustomRetargetingScript> registeredCharacters = new List<CustomRetargetingScript>();
|
||||||
|
|
||||||
private RetargetingHTTPServer httpServer;
|
|
||||||
private RetargetingWebSocketServer wsServer;
|
private RetargetingWebSocketServer wsServer;
|
||||||
|
|
||||||
private Queue<Action> mainThreadActions = new Queue<Action>();
|
private Queue<Action> mainThreadActions = new Queue<Action>();
|
||||||
|
|
||||||
public bool IsRunning => httpServer?.IsRunning == true && wsServer?.IsRunning == true;
|
public bool IsRunning => wsServer?.IsRunning == true;
|
||||||
public int HttpPort { get => httpPort; set => httpPort = value; }
|
|
||||||
public int WsPort { get => wsPort; set => wsPort = value; }
|
public int WsPort { get => wsPort; set => wsPort = value; }
|
||||||
public bool AutoStart { get => autoStart; set => autoStart = value; }
|
public bool AutoStart { get => autoStart; set => autoStart = value; }
|
||||||
|
|
||||||
@ -74,21 +72,16 @@ namespace KindRetargeting.Remote
|
|||||||
{
|
{
|
||||||
if (IsRunning) return;
|
if (IsRunning) return;
|
||||||
|
|
||||||
httpServer = new RetargetingHTTPServer(httpPort, wsPort);
|
|
||||||
wsServer = new RetargetingWebSocketServer(wsPort);
|
wsServer = new RetargetingWebSocketServer(wsPort);
|
||||||
|
|
||||||
wsServer.OnMessageReceived += OnWebSocketMessage;
|
wsServer.OnMessageReceived += OnWebSocketMessage;
|
||||||
|
|
||||||
httpServer.Start();
|
|
||||||
wsServer.Start();
|
wsServer.Start();
|
||||||
|
|
||||||
Debug.Log($"[RetargetingRemote] 서버 시작됨 - HTTP: {httpPort}, WS: {wsPort}");
|
Debug.Log($"[RetargetingRemote] WebSocket 서버 시작됨 - WS: {wsPort}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StopServer()
|
public void StopServer()
|
||||||
{
|
{
|
||||||
wsServer?.Stop();
|
wsServer?.Stop();
|
||||||
httpServer?.Stop();
|
|
||||||
|
|
||||||
if (wsServer != null)
|
if (wsServer != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 1391a0125a12fdb46bb61bc345044032
|
guid: 49e714e9f3f2ff84fabab3905897e4f3
|
||||||
folderAsset: yes
|
folderAsset: yes
|
||||||
DefaultImporter:
|
DefaultImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(StreamDeckServerManager))]
|
||||||
|
public class StreamDeckServerManagerEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/Streamdeck/Editor/UXML/StreamDeckServerManagerEditor.uxml";
|
||||||
|
private const string UssPath = "Assets/Scripts/Streamdeck/Editor/UXML/StreamDeckServerManagerEditor.uss";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private StreamDeckServerManager manager;
|
||||||
|
private VisualElement playStatusContainer;
|
||||||
|
private Label playStatusLabel;
|
||||||
|
private Label lanIPLabel;
|
||||||
|
private Label dashboardUrlLabel;
|
||||||
|
private VisualElement dashboardPortField;
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
manager = (StreamDeckServerManager)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
// Load stylesheets
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
|
if (uss != null) root.styleSheets.Add(uss);
|
||||||
|
|
||||||
|
// Load UXML
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
// Cache references
|
||||||
|
playStatusContainer = root.Q("playStatusContainer");
|
||||||
|
playStatusLabel = root.Q<Label>("playStatusLabel");
|
||||||
|
lanIPLabel = root.Q<Label>("lanIPLabel");
|
||||||
|
dashboardUrlLabel = root.Q<Label>("dashboardUrlLabel");
|
||||||
|
dashboardPortField = root.Q("dashboardPortField");
|
||||||
|
|
||||||
|
// Open Dashboard button (LAN IP 우선)
|
||||||
|
var openBtn = root.Q<Button>("openDashboardBtn");
|
||||||
|
if (openBtn != null)
|
||||||
|
openBtn.clicked += () =>
|
||||||
|
{
|
||||||
|
string ip = GetLocalIPAddress();
|
||||||
|
string host = !string.IsNullOrEmpty(ip) ? ip : "localhost";
|
||||||
|
Application.OpenURL($"http://{host}:{manager.dashboardPort}");
|
||||||
|
};
|
||||||
|
|
||||||
|
// LAN IP detection
|
||||||
|
string lanIP = GetLocalIPAddress();
|
||||||
|
if (lanIPLabel != null)
|
||||||
|
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
|
||||||
|
|
||||||
|
UpdateDashboardUrl(lanIP);
|
||||||
|
|
||||||
|
// Track enableDashboard for conditional visibility
|
||||||
|
var enableProp = serializedObject.FindProperty("enableDashboard");
|
||||||
|
UpdateDashboardPortVisibility(enableProp.boolValue);
|
||||||
|
root.TrackPropertyValue(enableProp, prop => UpdateDashboardPortVisibility(prop.boolValue));
|
||||||
|
|
||||||
|
// Track port changes to update URL display
|
||||||
|
var dashboardPortProp = serializedObject.FindProperty("dashboardPort");
|
||||||
|
var wsPortProp = serializedObject.FindProperty("port");
|
||||||
|
root.TrackPropertyValue(dashboardPortProp, _ => UpdateDashboardUrl(lanIP));
|
||||||
|
root.TrackPropertyValue(wsPortProp, _ => UpdateDashboardUrl(lanIP));
|
||||||
|
|
||||||
|
// Play mode polling
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(500);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDashboardUrl(string lanIP)
|
||||||
|
{
|
||||||
|
if (dashboardUrlLabel == null || manager == null) return;
|
||||||
|
|
||||||
|
string local = $"http://localhost:{manager.dashboardPort}";
|
||||||
|
if (!string.IsNullOrEmpty(lanIP))
|
||||||
|
dashboardUrlLabel.text = $"{local} | http://{lanIP}:{manager.dashboardPort}";
|
||||||
|
else
|
||||||
|
dashboardUrlLabel.text = local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDashboardPortVisibility(bool enabled)
|
||||||
|
{
|
||||||
|
if (dashboardPortField == null) return;
|
||||||
|
dashboardPortField.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePlayModeState()
|
||||||
|
{
|
||||||
|
if (playStatusContainer == null || manager == null) return;
|
||||||
|
|
||||||
|
bool isPlaying = Application.isPlaying;
|
||||||
|
if (isPlaying)
|
||||||
|
{
|
||||||
|
if (!playStatusContainer.ClassListContains("sdm-play-status--visible"))
|
||||||
|
playStatusContainer.AddToClassList("sdm-play-status--visible");
|
||||||
|
|
||||||
|
if (playStatusLabel != null)
|
||||||
|
playStatusLabel.text = $"Running ({manager.ConnectedClientCount} clients)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (playStatusContainer.ClassListContains("sdm-play-status--visible"))
|
||||||
|
playStatusContainer.RemoveFromClassList("sdm-play-status--visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLocalIPAddress()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var host = Dns.GetHostEntry(Dns.GetHostName());
|
||||||
|
foreach (var ip in host.AddressList)
|
||||||
|
{
|
||||||
|
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
return ip.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a876625bf5e790e4bab87197b239bd61
|
||||||
8
Assets/Scripts/Streamdeck/Editor/UXML.meta
Normal file
8
Assets/Scripts/Streamdeck/Editor/UXML.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d8b7827594b941743a8b687224c91d25
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
/* StreamDeckServerManager Editor Styles */
|
||||||
|
|
||||||
|
/* ---- Title Bar ---- */
|
||||||
|
|
||||||
|
.sdm-title-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-title-left {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-title-text {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-play-status {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-play-status--visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-play-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #22c55e;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-play-label {
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-dashboard-btn {
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
padding: 4px 14px;
|
||||||
|
height: 26px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-dashboard-btn:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-dashboard-btn:active {
|
||||||
|
background-color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LAN Info ---- */
|
||||||
|
|
||||||
|
.sdm-lan-box {
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-lan-ip {
|
||||||
|
font-size: 12px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdm-lan-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a23dd6c408af934cb024d0e16b53b37
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- Title + Dashboard Quick Access -->
|
||||||
|
<ui:VisualElement name="titleBar" class="sdm-title-bar">
|
||||||
|
<ui:VisualElement class="sdm-title-left">
|
||||||
|
<ui:Label text="Streamingle Server" class="sdm-title-text"/>
|
||||||
|
<ui:VisualElement name="playStatusContainer" class="sdm-play-status">
|
||||||
|
<ui:VisualElement name="playStatusDot" class="sdm-play-dot"/>
|
||||||
|
<ui:Label name="playStatusLabel" text="" class="sdm-play-label"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:Button name="openDashboardBtn" text="Open Dashboard" class="sdm-dashboard-btn"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- LAN Info -->
|
||||||
|
<ui:VisualElement name="lanInfoBox" class="sdm-lan-box">
|
||||||
|
<ui:Label name="lanIPLabel" text="LAN IP: detecting..." class="sdm-lan-ip"/>
|
||||||
|
<ui:Label name="dashboardUrlLabel" text="" class="sdm-lan-url"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- WebSocket Settings -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="WebSocket Server" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="port" label="WebSocket Port"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Dashboard Settings -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="Web Dashboard" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="enableDashboard" label="Enable Dashboard"/>
|
||||||
|
<ui:VisualElement name="dashboardPortField">
|
||||||
|
<uie:PropertyField binding-path="dashboardPort" label="Dashboard Port"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a1fe4d988343b8f4c8a707a2ea9d0aa0
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
File diff suppressed because it is too large
Load Diff
282
Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
Normal file
282
Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP 서버 - Streamingle 웹 대시보드 UI를 제공합니다.
|
||||||
|
/// Resources/StreamingleDashboard/ 폴더에서 HTML, CSS, JS를 로드합니다.
|
||||||
|
/// RetargetingHTTPServer 패턴을 따릅니다.
|
||||||
|
/// </summary>
|
||||||
|
public class StreamingleDashboardServer
|
||||||
|
{
|
||||||
|
private HttpListener listener;
|
||||||
|
private Thread listenerThread;
|
||||||
|
private bool isRunning = false;
|
||||||
|
|
||||||
|
private int httpPort;
|
||||||
|
private int wsPort;
|
||||||
|
private int retargetingWsPort;
|
||||||
|
|
||||||
|
private string cachedCSS = "";
|
||||||
|
private string cachedTemplate = "";
|
||||||
|
private string cachedJS = "";
|
||||||
|
|
||||||
|
private List<string> boundAddresses = new List<string>();
|
||||||
|
|
||||||
|
public bool IsRunning => isRunning;
|
||||||
|
public IReadOnlyList<string> BoundAddresses => boundAddresses;
|
||||||
|
|
||||||
|
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0)
|
||||||
|
{
|
||||||
|
this.httpPort = httpPort;
|
||||||
|
this.wsPort = wsPort;
|
||||||
|
this.retargetingWsPort = retargetingWsPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (isRunning) return;
|
||||||
|
|
||||||
|
LoadResources();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener = new HttpListener();
|
||||||
|
boundAddresses.Clear();
|
||||||
|
|
||||||
|
// http://+: 로 모든 인터페이스 바인딩 시도 (관리자 권한 또는 URL ACL 필요)
|
||||||
|
bool wildcardBound = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener.Prefixes.Add($"http://+:{httpPort}/");
|
||||||
|
listener.Start();
|
||||||
|
wildcardBound = true;
|
||||||
|
|
||||||
|
boundAddresses.Add($"http://localhost:{httpPort}");
|
||||||
|
string localIP = GetLocalIPAddress();
|
||||||
|
if (!string.IsNullOrEmpty(localIP))
|
||||||
|
{
|
||||||
|
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
||||||
|
}
|
||||||
|
Debug.Log("[StreamingleDashboard] 모든 인터페이스 바인딩 성공 (LAN 접속 가능)");
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 와일드카드 실패 시 리스너 재생성
|
||||||
|
try { listener.Close(); } catch { }
|
||||||
|
listener = new HttpListener();
|
||||||
|
|
||||||
|
// LAN IP 직접 바인딩 시도
|
||||||
|
string localIP = GetLocalIPAddress();
|
||||||
|
bool lanBound = false;
|
||||||
|
|
||||||
|
listener.Prefixes.Add($"http://localhost:{httpPort}/");
|
||||||
|
boundAddresses.Add($"http://localhost:{httpPort}");
|
||||||
|
|
||||||
|
listener.Prefixes.Add($"http://127.0.0.1:{httpPort}/");
|
||||||
|
boundAddresses.Add($"http://127.0.0.1:{httpPort}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(localIP))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener.Prefixes.Add($"http://{localIP}:{httpPort}/");
|
||||||
|
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
||||||
|
lanBound = true;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// LAN IP 바인딩도 실패
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
if (lanBound)
|
||||||
|
Debug.Log($"[StreamingleDashboard] LAN IP 바인딩 성공 ({localIP})");
|
||||||
|
else
|
||||||
|
Debug.LogWarning("[StreamingleDashboard] LAN 바인딩 실패. localhost만 접속 가능. 관리자 권한으로 실행하거나 다음 명령어를 실행하세요:\n" +
|
||||||
|
$" netsh http add urlacl url=http://+:{httpPort}/ user=Everyone");
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
|
||||||
|
listenerThread = new Thread(HandleRequests);
|
||||||
|
listenerThread.IsBackground = true;
|
||||||
|
listenerThread.Start();
|
||||||
|
|
||||||
|
Debug.Log("[StreamingleDashboard] HTTP 서버 시작됨");
|
||||||
|
foreach (var addr in boundAddresses)
|
||||||
|
{
|
||||||
|
Debug.Log($"[StreamingleDashboard] 대시보드 접속: {addr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[StreamingleDashboard] 서버 시작 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
isRunning = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener?.Stop();
|
||||||
|
listener?.Close();
|
||||||
|
}
|
||||||
|
catch (Exception) { }
|
||||||
|
|
||||||
|
Debug.Log("[StreamingleDashboard] HTTP 서버 중지됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadResources()
|
||||||
|
{
|
||||||
|
TextAsset cssAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_style");
|
||||||
|
TextAsset templateAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_template");
|
||||||
|
TextAsset jsAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_script");
|
||||||
|
|
||||||
|
cachedCSS = cssAsset != null ? cssAsset.text : GetFallbackCSS();
|
||||||
|
cachedTemplate = templateAsset != null ? templateAsset.text : GetFallbackTemplate();
|
||||||
|
cachedJS = jsAsset != null ? jsAsset.text : GetFallbackJS();
|
||||||
|
|
||||||
|
if (cssAsset == null || templateAsset == null || jsAsset == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[StreamingleDashboard] 일부 리소스를 로드할 수 없습니다. Fallback 사용 중.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRequests()
|
||||||
|
{
|
||||||
|
while (isRunning)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HttpListenerContext context = listener.GetContext();
|
||||||
|
ProcessRequest(context);
|
||||||
|
}
|
||||||
|
catch (HttpListenerException)
|
||||||
|
{
|
||||||
|
// 서버 종료 시 발생
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (isRunning)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessRequest(HttpListenerContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = context.Request.Url.AbsolutePath;
|
||||||
|
|
||||||
|
string responseContent;
|
||||||
|
string contentType;
|
||||||
|
|
||||||
|
if (path == "/" || path == "/index.html")
|
||||||
|
{
|
||||||
|
responseContent = GenerateHTML();
|
||||||
|
contentType = "text/html; charset=utf-8";
|
||||||
|
}
|
||||||
|
else if (path == "/style.css")
|
||||||
|
{
|
||||||
|
responseContent = cachedCSS;
|
||||||
|
contentType = "text/css; charset=utf-8";
|
||||||
|
}
|
||||||
|
else if (path == "/script.js")
|
||||||
|
{
|
||||||
|
responseContent = cachedJS
|
||||||
|
.Replace("{{WS_PORT}}", wsPort.ToString())
|
||||||
|
.Replace("{{RETARGETING_WS_PORT}}", retargetingWsPort.ToString());
|
||||||
|
contentType = "application/javascript; charset=utf-8";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
responseContent = "Not Found";
|
||||||
|
contentType = "text/plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
|
||||||
|
context.Response.ContentType = contentType;
|
||||||
|
context.Response.ContentLength64 = buffer.Length;
|
||||||
|
context.Response.AddHeader("Cache-Control", "no-cache");
|
||||||
|
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
|
||||||
|
context.Response.Close();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[StreamingleDashboard] 응답 처리 오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateHTML()
|
||||||
|
{
|
||||||
|
string html = cachedTemplate;
|
||||||
|
html = html.Replace("{{CSS}}", cachedCSS);
|
||||||
|
html = html.Replace("{{JS}}", cachedJS
|
||||||
|
.Replace("{{WS_PORT}}", wsPort.ToString())
|
||||||
|
.Replace("{{RETARGETING_WS_PORT}}", retargetingWsPort.ToString()));
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLocalIPAddress()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var host = Dns.GetHostEntry(Dns.GetHostName());
|
||||||
|
foreach (var ip in host.AddressList)
|
||||||
|
{
|
||||||
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return ip.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception) { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFallbackCSS()
|
||||||
|
{
|
||||||
|
return @"
|
||||||
|
body { font-family: sans-serif; background: #0f172a; color: #f1f5f9; padding: 20px; }
|
||||||
|
.error { color: #ef4444; padding: 20px; text-align: center; }
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFallbackTemplate()
|
||||||
|
{
|
||||||
|
return @"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset=""UTF-8"">
|
||||||
|
<title>Streamingle Dashboard</title>
|
||||||
|
<style>{{CSS}}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class=""error"">
|
||||||
|
리소스 파일을 찾을 수 없습니다.<br>
|
||||||
|
Assets/Resources/StreamingleDashboard/ 폴더에 파일이 있는지 확인해주세요.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFallbackJS()
|
||||||
|
{
|
||||||
|
return "console.error('JavaScript resource not found');";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 64ec23e020e1e6d408eec3d0a0460741
|
||||||
@ -5,7 +5,7 @@ using System.Collections.Generic;
|
|||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using UnityEngine.EventSystems;
|
using UnityEngine.EventSystems;
|
||||||
using Streamingle.Debug;
|
using Streamingle.Debugging;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace Streamingle
|
namespace Streamingle
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Unity.Cinemachine;
|
using Unity.Cinemachine;
|
||||||
using UnityRawInput;
|
|
||||||
using UnityEngine.Rendering;
|
using UnityEngine.Rendering;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@ -82,26 +81,10 @@ public class CameraControlSystem : MonoBehaviour
|
|||||||
|
|
||||||
// DOF 타겟 찾기
|
// DOF 타겟 찾기
|
||||||
FindDOFTargets();
|
FindDOFTargets();
|
||||||
|
|
||||||
InitializeRawInput();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
// RawInput 정리
|
|
||||||
if (RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
// 다른 컴포넌트가 사용 중일 수 있으므로 Stop은 호출하지 않음
|
|
||||||
}
|
|
||||||
catch (System.Exception ex)
|
|
||||||
{
|
|
||||||
// RawInput 정리 실패 무시
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CameraManager 이벤트 해제
|
// CameraManager 이벤트 해제
|
||||||
if (cameraManager != null)
|
if (cameraManager != null)
|
||||||
{
|
{
|
||||||
@ -117,27 +100,6 @@ public class CameraControlSystem : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeRawInput()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.Start();
|
|
||||||
RawInput.WorkInBackground = true; // 백그라운드에서도 키 입력 감지
|
|
||||||
RawInput.InterceptMessages = false; // 다른 앱으로 키 메시지 전달
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이벤트 중복 등록 방지
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.OnKeyDown += HandleRawKeyDown;
|
|
||||||
}
|
|
||||||
catch (System.Exception ex)
|
|
||||||
{
|
|
||||||
// RawInput 실패 시 Unity Input으로 대체
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
HandleFOVControl();
|
HandleFOVControl();
|
||||||
@ -146,36 +108,8 @@ public class CameraControlSystem : MonoBehaviour
|
|||||||
UpdateDOFTargetTracking();
|
UpdateDOFTargetTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleRawKeyDown(RawKey key)
|
|
||||||
{
|
|
||||||
switch (key)
|
|
||||||
{
|
|
||||||
case RawKey.F13:
|
|
||||||
if (isFOVMode) IncreaseFOV();
|
|
||||||
else IncreaseDOF();
|
|
||||||
break;
|
|
||||||
case RawKey.F14:
|
|
||||||
if (isFOVMode) DecreaseFOV();
|
|
||||||
else DecreaseDOF();
|
|
||||||
break;
|
|
||||||
case RawKey.F15:
|
|
||||||
ToggleControlMode();
|
|
||||||
break;
|
|
||||||
case RawKey.F16:
|
|
||||||
TakeScreenshot();
|
|
||||||
break;
|
|
||||||
case RawKey.F17:
|
|
||||||
ToggleCameraUI();
|
|
||||||
break;
|
|
||||||
case RawKey.F18:
|
|
||||||
HandleF18Click();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleFOVControl()
|
private void HandleFOVControl()
|
||||||
{
|
{
|
||||||
// Unity Input으로도 처리 (RawInput과 병행하여 안정성 확보)
|
|
||||||
if (Input.GetKeyDown(KeyCode.F13))
|
if (Input.GetKeyDown(KeyCode.F13))
|
||||||
{
|
{
|
||||||
if (isFOVMode) IncreaseFOV();
|
if (isFOVMode) IncreaseFOV();
|
||||||
|
|||||||
@ -3,7 +3,6 @@ using UnityEngine.Rendering;
|
|||||||
using UnityEngine.Rendering.Universal;
|
using UnityEngine.Rendering.Universal;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityRawInput;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Unity.Cinemachine;
|
using Unity.Cinemachine;
|
||||||
using Streamingle;
|
using Streamingle;
|
||||||
@ -12,127 +11,11 @@ using KindRetargeting;
|
|||||||
public class CameraManager : MonoBehaviour, IController
|
public class CameraManager : MonoBehaviour, IController
|
||||||
{
|
{
|
||||||
#region Classes
|
#region Classes
|
||||||
private static class KeyMapping
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<KeyCode, RawKey> _mapping;
|
|
||||||
|
|
||||||
static KeyMapping()
|
|
||||||
{
|
|
||||||
_mapping = new Dictionary<KeyCode, RawKey>(RawKeySetup.KeyMapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetRawKey(KeyCode keyCode, out RawKey rawKey)
|
|
||||||
{
|
|
||||||
return _mapping.TryGetValue(keyCode, out rawKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetKeyCode(RawKey rawKey, out KeyCode keyCode)
|
|
||||||
{
|
|
||||||
var pair = _mapping.FirstOrDefault(x => x.Value == rawKey);
|
|
||||||
keyCode = pair.Key;
|
|
||||||
return keyCode != KeyCode.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsValidRawKey(RawKey key)
|
|
||||||
{
|
|
||||||
return _mapping.ContainsValue(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.Serializable]
|
|
||||||
public class HotkeyCommand
|
|
||||||
{
|
|
||||||
public List<RawKey> rawKeys = new List<RawKey>();
|
|
||||||
[System.NonSerialized] private List<KeyCode> unityKeys = new List<KeyCode>();
|
|
||||||
[System.NonSerialized] public bool isRecording = false;
|
|
||||||
[System.NonSerialized] private float recordStartTime;
|
|
||||||
[System.NonSerialized] private const float MAX_RECORD_TIME = 2f;
|
|
||||||
|
|
||||||
public void StartRecording()
|
|
||||||
{
|
|
||||||
isRecording = true;
|
|
||||||
recordStartTime = Time.time;
|
|
||||||
rawKeys.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecording()
|
|
||||||
{
|
|
||||||
isRecording = false;
|
|
||||||
InitializeUnityKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateRecording()
|
|
||||||
{
|
|
||||||
if (!isRecording) return;
|
|
||||||
|
|
||||||
if (Time.time - recordStartTime > MAX_RECORD_TIME)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (KeyCode keyCode in System.Enum.GetValues(typeof(KeyCode)))
|
|
||||||
{
|
|
||||||
if (Input.GetKeyDown(keyCode) && KeyMapping.TryGetRawKey(keyCode, out RawKey rawKey))
|
|
||||||
{
|
|
||||||
if (!rawKeys.Contains(rawKey))
|
|
||||||
{
|
|
||||||
rawKeys.Add(rawKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool allKeysReleased = rawKeys.Any() && rawKeys.All(key => !Input.GetKey(KeyMapping.TryGetKeyCode(key, out KeyCode keyCode) ? keyCode : KeyCode.None));
|
|
||||||
|
|
||||||
if (allKeysReleased)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitializeUnityKeys()
|
|
||||||
{
|
|
||||||
unityKeys.Clear();
|
|
||||||
|
|
||||||
if (rawKeys == null || !rawKeys.Any()) return;
|
|
||||||
|
|
||||||
foreach (var rawKey in rawKeys)
|
|
||||||
{
|
|
||||||
if (KeyMapping.TryGetKeyCode(rawKey, out KeyCode keyCode) && keyCode != KeyCode.None)
|
|
||||||
{
|
|
||||||
unityKeys.Add(keyCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsTriggered()
|
|
||||||
{
|
|
||||||
if (isRecording) return false;
|
|
||||||
|
|
||||||
if (rawKeys == null || !rawKeys.Any()) return false;
|
|
||||||
|
|
||||||
bool allRawKeysPressed = rawKeys.All(key => RawInput.IsKeyDown(key));
|
|
||||||
if (allRawKeysPressed) return true;
|
|
||||||
|
|
||||||
if (unityKeys.Any())
|
|
||||||
{
|
|
||||||
return unityKeys.All(key => Input.GetKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() =>
|
|
||||||
rawKeys?.Any() == true ? string.Join(" + ", rawKeys) : "설정되지 않음";
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
public class CameraPreset : ISerializationCallbackReceiver
|
public class CameraPreset : ISerializationCallbackReceiver
|
||||||
{
|
{
|
||||||
public string presetName = "New Camera Preset";
|
public string presetName = "New Camera Preset";
|
||||||
public CinemachineCamera virtualCamera;
|
public CinemachineCamera virtualCamera;
|
||||||
public HotkeyCommand hotkey;
|
|
||||||
[System.NonSerialized] public bool isEditingHotkey = false;
|
|
||||||
|
|
||||||
// 마우스 조작 허용 여부
|
// 마우스 조작 허용 여부
|
||||||
[Tooltip("이 카메라에서 마우스 조작(회전, 팬, 줌 등)을 허용할지 여부")]
|
[Tooltip("이 카메라에서 마우스 조작(회전, 팬, 줌 등)을 허용할지 여부")]
|
||||||
@ -174,11 +57,10 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
{
|
{
|
||||||
virtualCamera = camera;
|
virtualCamera = camera;
|
||||||
presetName = camera?.gameObject.name ?? "Unnamed Camera";
|
presetName = camera?.gameObject.name ?? "Unnamed Camera";
|
||||||
hotkey = new HotkeyCommand();
|
|
||||||
allowMouseControl = true;
|
allowMouseControl = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsValid() => virtualCamera != null && hotkey != null;
|
public bool IsValid() => virtualCamera != null;
|
||||||
|
|
||||||
// 초기 상태 저장 (Alt+Q 복원용)
|
// 초기 상태 저장 (Alt+Q 복원용)
|
||||||
public void SaveInitialState()
|
public void SaveInitialState()
|
||||||
@ -336,7 +218,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
InitializeInputHandler();
|
InitializeInputHandler();
|
||||||
InitializeRawInput();
|
|
||||||
InitializeCameraPresets();
|
InitializeCameraPresets();
|
||||||
|
|
||||||
// StreamDeckServerManager 찾기
|
// StreamDeckServerManager 찾기
|
||||||
@ -358,12 +239,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
if (RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 블렌드 리소스 정리
|
// 블렌드 리소스 정리
|
||||||
if (prevCameraRenderTexture != null)
|
if (prevCameraRenderTexture != null)
|
||||||
{
|
{
|
||||||
@ -386,20 +261,7 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
|
|
||||||
if (!IsValidSetup) return;
|
if (!IsValidSetup) return;
|
||||||
|
|
||||||
UpdateHotkeyRecording();
|
|
||||||
HandleCameraControls();
|
HandleCameraControls();
|
||||||
HandleHotkeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateHotkeyRecording()
|
|
||||||
{
|
|
||||||
foreach (var preset in cameraPresets)
|
|
||||||
{
|
|
||||||
if (preset?.hotkey?.isRecording == true)
|
|
||||||
{
|
|
||||||
preset.hotkey.UpdateRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleGamepadButtons()
|
private void HandleGamepadButtons()
|
||||||
@ -534,18 +396,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeRawInput()
|
|
||||||
{
|
|
||||||
if (!RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.Start();
|
|
||||||
RawInput.WorkInBackground = true;
|
|
||||||
}
|
|
||||||
// 중복 구독 방지를 위해 먼저 해제 후 구독
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.OnKeyDown += HandleRawKeyDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeCameraPresets()
|
private void InitializeCameraPresets()
|
||||||
{
|
{
|
||||||
if (cameraPresets == null)
|
if (cameraPresets == null)
|
||||||
@ -558,11 +408,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var preset in cameraPresets.Where(p => p?.hotkey != null))
|
|
||||||
{
|
|
||||||
preset.hotkey.InitializeUnityKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 프리셋의 초기 상태 저장
|
// 모든 프리셋의 초기 상태 저장
|
||||||
foreach (var preset in cameraPresets.Where(p => p?.IsValid() == true))
|
foreach (var preset in cameraPresets.Where(p => p?.IsValid() == true))
|
||||||
{
|
{
|
||||||
@ -631,39 +476,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Input Handling
|
|
||||||
private void HandleRawKeyDown(RawKey key)
|
|
||||||
{
|
|
||||||
if (key == default(RawKey)) return;
|
|
||||||
TryActivatePresetByInput(preset =>
|
|
||||||
{
|
|
||||||
if (preset?.hotkey == null) return false;
|
|
||||||
return preset.hotkey.IsTriggered();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleHotkeys()
|
|
||||||
{
|
|
||||||
if (Input.anyKeyDown)
|
|
||||||
{
|
|
||||||
TryActivatePresetByInput(preset =>
|
|
||||||
{
|
|
||||||
if (preset?.hotkey == null) return false;
|
|
||||||
return preset.hotkey.IsTriggered();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryActivatePresetByInput(System.Func<CameraPreset, bool> predicate)
|
|
||||||
{
|
|
||||||
var matchingPreset = cameraPresets?.FirstOrDefault(predicate);
|
|
||||||
if (matchingPreset != null)
|
|
||||||
{
|
|
||||||
Set(cameraPresets.IndexOf(matchingPreset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Camera Controls
|
#region Camera Controls
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -1350,37 +1162,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Hotkey Management
|
|
||||||
public void StartRecordingHotkey(int presetIndex)
|
|
||||||
{
|
|
||||||
if (cameraPresets == null || presetIndex < 0 || presetIndex >= cameraPresets.Count) return;
|
|
||||||
|
|
||||||
var preset = cameraPresets[presetIndex];
|
|
||||||
if (!preset.IsValid()) return;
|
|
||||||
|
|
||||||
foreach (var otherPreset in cameraPresets)
|
|
||||||
{
|
|
||||||
if (otherPreset?.hotkey?.isRecording == true)
|
|
||||||
{
|
|
||||||
otherPreset.hotkey.StopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preset.hotkey.StartRecording();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecordingHotkey(int presetIndex)
|
|
||||||
{
|
|
||||||
if (cameraPresets == null || presetIndex < 0 || presetIndex >= cameraPresets.Count) return;
|
|
||||||
|
|
||||||
var preset = cameraPresets[presetIndex];
|
|
||||||
if (preset?.hotkey?.isRecording == true)
|
|
||||||
{
|
|
||||||
preset.hotkey.StopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Camera Data
|
#region Camera Data
|
||||||
// 카메라 목록 데이터 반환 (HTTP 요청 시 직접 호출됨)
|
// 카메라 목록 데이터 반환 (HTTP 요청 시 직접 호출됨)
|
||||||
public CameraListData GetCameraListData()
|
public CameraListData GetCameraListData()
|
||||||
@ -1389,8 +1170,7 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
{
|
{
|
||||||
index = index,
|
index = index,
|
||||||
name = preset?.virtualCamera?.gameObject.name ?? $"Camera {index}",
|
name = preset?.virtualCamera?.gameObject.name ?? $"Camera {index}",
|
||||||
isActive = currentPreset == preset,
|
isActive = currentPreset == preset
|
||||||
hotkey = preset?.hotkey?.ToString() ?? "설정되지 않음"
|
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
return new CameraListData
|
return new CameraListData
|
||||||
@ -1423,7 +1203,6 @@ public class CameraManager : MonoBehaviour, IController
|
|||||||
public int index;
|
public int index;
|
||||||
public string name;
|
public string name;
|
||||||
public bool isActive;
|
public bool isActive;
|
||||||
public string hotkey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
|
|||||||
@ -2,7 +2,6 @@ using UnityEngine;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Streamingle;
|
using Streamingle;
|
||||||
using UnityRawInput;
|
|
||||||
using UnityEngine.Events;
|
using UnityEngine.Events;
|
||||||
|
|
||||||
public class EventController : MonoBehaviour, IController
|
public class EventController : MonoBehaviour, IController
|
||||||
@ -15,94 +14,12 @@ public class EventController : MonoBehaviour, IController
|
|||||||
public string groupName = "New Event Group";
|
public string groupName = "New Event Group";
|
||||||
public UnityEvent unityEvent = new UnityEvent();
|
public UnityEvent unityEvent = new UnityEvent();
|
||||||
|
|
||||||
[Header("Hotkey Settings")]
|
|
||||||
public List<RawKey> hotkeys = new List<RawKey>();
|
|
||||||
[System.NonSerialized] public bool isRecording = false;
|
|
||||||
[System.NonSerialized] private List<KeyCode> unityKeys = new List<KeyCode>();
|
|
||||||
[System.NonSerialized] private float recordStartTime;
|
|
||||||
[System.NonSerialized] private const float MAX_RECORD_TIME = 2f;
|
|
||||||
|
|
||||||
public EventGroup(string name)
|
public EventGroup(string name)
|
||||||
{
|
{
|
||||||
groupName = name;
|
groupName = name;
|
||||||
hotkeys = new List<RawKey>();
|
|
||||||
unityEvent = new UnityEvent();
|
unityEvent = new UnityEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartRecording()
|
|
||||||
{
|
|
||||||
isRecording = true;
|
|
||||||
recordStartTime = Time.time;
|
|
||||||
hotkeys.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecording()
|
|
||||||
{
|
|
||||||
isRecording = false;
|
|
||||||
InitializeUnityKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateRecording()
|
|
||||||
{
|
|
||||||
if (!isRecording) return;
|
|
||||||
|
|
||||||
if (Time.time - recordStartTime > MAX_RECORD_TIME)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (KeyCode keyCode in System.Enum.GetValues(typeof(KeyCode)))
|
|
||||||
{
|
|
||||||
if (Input.GetKeyDown(keyCode) && KeyMapping.TryGetRawKey(keyCode, out RawKey rawKey))
|
|
||||||
{
|
|
||||||
if (!hotkeys.Contains(rawKey))
|
|
||||||
{
|
|
||||||
hotkeys.Add(rawKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool allKeysReleased = hotkeys.Any() && hotkeys.All(key => !Input.GetKey(KeyMapping.TryGetKeyCode(key, out KeyCode keyCode) ? keyCode : KeyCode.None));
|
|
||||||
|
|
||||||
if (allKeysReleased)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitializeUnityKeys()
|
|
||||||
{
|
|
||||||
unityKeys.Clear();
|
|
||||||
|
|
||||||
if (hotkeys == null || !hotkeys.Any()) return;
|
|
||||||
|
|
||||||
foreach (var hotkey in hotkeys)
|
|
||||||
{
|
|
||||||
if (KeyMapping.TryGetKeyCode(hotkey, out KeyCode keyCode) && keyCode != KeyCode.None)
|
|
||||||
{
|
|
||||||
unityKeys.Add(keyCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsTriggered()
|
|
||||||
{
|
|
||||||
if (isRecording) return false;
|
|
||||||
|
|
||||||
if (hotkeys == null || !hotkeys.Any()) return false;
|
|
||||||
|
|
||||||
bool allHotkeysPressed = hotkeys.All(key => RawInput.IsKeyDown(key));
|
|
||||||
if (allHotkeysPressed) return true;
|
|
||||||
|
|
||||||
if (unityKeys.Any())
|
|
||||||
{
|
|
||||||
return unityKeys.All(key => Input.GetKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ExecuteEvent()
|
public void ExecuteEvent()
|
||||||
{
|
{
|
||||||
if (unityEvent != null)
|
if (unityEvent != null)
|
||||||
@ -111,35 +28,7 @@ public class EventController : MonoBehaviour, IController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() =>
|
public override string ToString() => groupName;
|
||||||
hotkeys?.Any() == true ? $"{groupName} ({string.Join(" + ", hotkeys)})" : $"{groupName} (설정되지 않음)";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class KeyMapping
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<KeyCode, RawKey> _mapping;
|
|
||||||
|
|
||||||
static KeyMapping()
|
|
||||||
{
|
|
||||||
_mapping = new Dictionary<KeyCode, RawKey>(RawKeySetup.KeyMapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetRawKey(KeyCode keyCode, out RawKey rawKey)
|
|
||||||
{
|
|
||||||
return _mapping.TryGetValue(keyCode, out rawKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetKeyCode(RawKey rawKey, out KeyCode keyCode)
|
|
||||||
{
|
|
||||||
var pair = _mapping.FirstOrDefault(x => x.Value == rawKey);
|
|
||||||
keyCode = pair.Key;
|
|
||||||
return keyCode != KeyCode.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsValidRawKey(RawKey key)
|
|
||||||
{
|
|
||||||
return _mapping.ContainsValue(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -167,7 +56,6 @@ public class EventController : MonoBehaviour, IController
|
|||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
InitializeEventGroups();
|
InitializeEventGroups();
|
||||||
InitializeRawInput();
|
|
||||||
|
|
||||||
// StreamDeckServerManager 찾기
|
// StreamDeckServerManager 찾기
|
||||||
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
||||||
@ -176,21 +64,6 @@ public class EventController : MonoBehaviour, IController
|
|||||||
Debug.LogWarning("[EventController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
Debug.LogWarning("[EventController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
|
||||||
{
|
|
||||||
if (RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Update()
|
|
||||||
{
|
|
||||||
UpdateHotkeyRecording();
|
|
||||||
HandleHotkeys();
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Initialization
|
#region Initialization
|
||||||
@ -206,46 +79,6 @@ public class EventController : MonoBehaviour, IController
|
|||||||
|
|
||||||
Debug.Log($"[EventController] {eventGroups.Count}개의 이벤트 그룹이 초기화되었습니다.");
|
Debug.Log($"[EventController] {eventGroups.Count}개의 이벤트 그룹이 초기화되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeRawInput()
|
|
||||||
{
|
|
||||||
if (!RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.Start();
|
|
||||||
RawInput.WorkInBackground = true;
|
|
||||||
}
|
|
||||||
// 중복 구독 방지를 위해 먼저 해제 후 구독
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.OnKeyDown += HandleRawKeyDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateHotkeyRecording()
|
|
||||||
{
|
|
||||||
foreach (var group in eventGroups)
|
|
||||||
{
|
|
||||||
if (group.isRecording)
|
|
||||||
{
|
|
||||||
group.UpdateRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleRawKeyDown(RawKey key)
|
|
||||||
{
|
|
||||||
// 핫키 레코딩 중일 때는 처리하지 않음
|
|
||||||
if (eventGroups.Any(g => g.isRecording)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleHotkeys()
|
|
||||||
{
|
|
||||||
foreach (var group in eventGroups)
|
|
||||||
{
|
|
||||||
if (group.IsTriggered())
|
|
||||||
{
|
|
||||||
ExecuteEvent(group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Public Methods
|
#region Public Methods
|
||||||
@ -348,30 +181,6 @@ public class EventController : MonoBehaviour, IController
|
|||||||
Debug.Log($"[EventController] 이벤트 그룹 제거: {groupToRemove.groupName}");
|
Debug.Log($"[EventController] 이벤트 그룹 제거: {groupToRemove.groupName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartRecordingHotkey(int groupIndex)
|
|
||||||
{
|
|
||||||
if (groupIndex < 0 || groupIndex >= eventGroups.Count)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"[EventController] 유효하지 않은 인덱스: {groupIndex}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
eventGroups[groupIndex].StartRecording();
|
|
||||||
Debug.Log($"[EventController] 핫키 레코딩 시작: {eventGroups[groupIndex].groupName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecordingHotkey(int groupIndex)
|
|
||||||
{
|
|
||||||
if (groupIndex < 0 || groupIndex >= eventGroups.Count)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"[EventController] 유효하지 않은 인덱스: {groupIndex}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
eventGroups[groupIndex].StopRecording();
|
|
||||||
Debug.Log($"[EventController] 핫키 레코딩 종료: {eventGroups[groupIndex].groupName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NotifyEventChanged()
|
private void NotifyEventChanged()
|
||||||
{
|
{
|
||||||
// StreamDeck에 이벤트 변경 알림
|
// StreamDeck에 이벤트 변경 알림
|
||||||
@ -393,8 +202,7 @@ public class EventController : MonoBehaviour, IController
|
|||||||
eventData[i] = new EventPresetData
|
eventData[i] = new EventPresetData
|
||||||
{
|
{
|
||||||
index = i,
|
index = i,
|
||||||
name = group.groupName,
|
name = group.groupName
|
||||||
hotkey = group.hotkeys.Any() ? string.Join(" + ", group.hotkeys) : "설정되지 않음"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,7 +241,6 @@ public class EventController : MonoBehaviour, IController
|
|||||||
{
|
{
|
||||||
public int index;
|
public int index;
|
||||||
public string name;
|
public string name;
|
||||||
public string hotkey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
|
|||||||
@ -2,7 +2,6 @@ using UnityEngine;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Streamingle;
|
using Streamingle;
|
||||||
using UnityRawInput;
|
|
||||||
|
|
||||||
public class ItemController : MonoBehaviour, IController
|
public class ItemController : MonoBehaviour, IController
|
||||||
{
|
{
|
||||||
@ -14,91 +13,9 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
public string groupName = "New Item Group";
|
public string groupName = "New Item Group";
|
||||||
public GameObject[] itemObjects = new GameObject[0];
|
public GameObject[] itemObjects = new GameObject[0];
|
||||||
|
|
||||||
[Header("Hotkey Settings")]
|
|
||||||
public List<RawKey> hotkeys = new List<RawKey>();
|
|
||||||
[System.NonSerialized] public bool isRecording = false;
|
|
||||||
[System.NonSerialized] private List<KeyCode> unityKeys = new List<KeyCode>();
|
|
||||||
[System.NonSerialized] private float recordStartTime;
|
|
||||||
[System.NonSerialized] private const float MAX_RECORD_TIME = 2f;
|
|
||||||
|
|
||||||
public ItemGroup(string name)
|
public ItemGroup(string name)
|
||||||
{
|
{
|
||||||
groupName = name;
|
groupName = name;
|
||||||
hotkeys = new List<RawKey>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StartRecording()
|
|
||||||
{
|
|
||||||
isRecording = true;
|
|
||||||
recordStartTime = Time.time;
|
|
||||||
hotkeys.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecording()
|
|
||||||
{
|
|
||||||
isRecording = false;
|
|
||||||
InitializeUnityKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateRecording()
|
|
||||||
{
|
|
||||||
if (!isRecording) return;
|
|
||||||
|
|
||||||
if (Time.time - recordStartTime > MAX_RECORD_TIME)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (KeyCode keyCode in System.Enum.GetValues(typeof(KeyCode)))
|
|
||||||
{
|
|
||||||
if (Input.GetKeyDown(keyCode) && KeyMapping.TryGetRawKey(keyCode, out RawKey rawKey))
|
|
||||||
{
|
|
||||||
if (!hotkeys.Contains(rawKey))
|
|
||||||
{
|
|
||||||
hotkeys.Add(rawKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool allKeysReleased = hotkeys.Any() && hotkeys.All(key => !Input.GetKey(KeyMapping.TryGetKeyCode(key, out KeyCode keyCode) ? keyCode : KeyCode.None));
|
|
||||||
|
|
||||||
if (allKeysReleased)
|
|
||||||
{
|
|
||||||
StopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitializeUnityKeys()
|
|
||||||
{
|
|
||||||
unityKeys.Clear();
|
|
||||||
|
|
||||||
if (hotkeys == null || !hotkeys.Any()) return;
|
|
||||||
|
|
||||||
foreach (var hotkey in hotkeys)
|
|
||||||
{
|
|
||||||
if (KeyMapping.TryGetKeyCode(hotkey, out KeyCode keyCode) && keyCode != KeyCode.None)
|
|
||||||
{
|
|
||||||
unityKeys.Add(keyCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsTriggered()
|
|
||||||
{
|
|
||||||
if (isRecording) return false;
|
|
||||||
|
|
||||||
if (hotkeys == null || !hotkeys.Any()) return false;
|
|
||||||
|
|
||||||
bool allHotkeysPressed = hotkeys.All(key => RawInput.IsKeyDown(key));
|
|
||||||
if (allHotkeysPressed) return true;
|
|
||||||
|
|
||||||
if (unityKeys.Any())
|
|
||||||
{
|
|
||||||
return unityKeys.All(key => Input.GetKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetActive(bool active)
|
public void SetActive(bool active)
|
||||||
@ -122,35 +39,7 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
return itemObjects.All(obj => obj != null && obj.activeSelf);
|
return itemObjects.All(obj => obj != null && obj.activeSelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() =>
|
public override string ToString() => groupName;
|
||||||
hotkeys?.Any() == true ? $"{groupName} ({string.Join(" + ", hotkeys)})" : $"{groupName} (설정되지 않음)";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class KeyMapping
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<KeyCode, RawKey> _mapping;
|
|
||||||
|
|
||||||
static KeyMapping()
|
|
||||||
{
|
|
||||||
_mapping = new Dictionary<KeyCode, RawKey>(RawKeySetup.KeyMapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetRawKey(KeyCode keyCode, out RawKey rawKey)
|
|
||||||
{
|
|
||||||
return _mapping.TryGetValue(keyCode, out rawKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetKeyCode(RawKey rawKey, out KeyCode keyCode)
|
|
||||||
{
|
|
||||||
var pair = _mapping.FirstOrDefault(x => x.Value == rawKey);
|
|
||||||
keyCode = pair.Key;
|
|
||||||
return keyCode != KeyCode.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsValidRawKey(RawKey key)
|
|
||||||
{
|
|
||||||
return _mapping.ContainsValue(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -179,7 +68,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
InitializeItemGroups();
|
InitializeItemGroups();
|
||||||
InitializeRawInput();
|
|
||||||
|
|
||||||
// StreamDeckServerManager 찾기
|
// StreamDeckServerManager 찾기
|
||||||
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
||||||
@ -188,21 +76,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
Debug.LogWarning("[ItemController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
Debug.LogWarning("[ItemController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
|
||||||
{
|
|
||||||
if (RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Update()
|
|
||||||
{
|
|
||||||
UpdateHotkeyRecording();
|
|
||||||
HandleHotkeys();
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Initialization
|
#region Initialization
|
||||||
@ -231,49 +104,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
|
|
||||||
Debug.Log($"[ItemController] 총 {itemGroups.Count}개의 아이템 그룹이 등록되었습니다.");
|
Debug.Log($"[ItemController] 총 {itemGroups.Count}개의 아이템 그룹이 등록되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeRawInput()
|
|
||||||
{
|
|
||||||
if (!RawInput.IsRunning)
|
|
||||||
{
|
|
||||||
RawInput.Start();
|
|
||||||
RawInput.WorkInBackground = true;
|
|
||||||
}
|
|
||||||
// 중복 구독 방지를 위해 먼저 해제 후 구독
|
|
||||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
|
||||||
RawInput.OnKeyDown += HandleRawKeyDown;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Hotkey Management
|
|
||||||
private void UpdateHotkeyRecording()
|
|
||||||
{
|
|
||||||
foreach (var group in itemGroups)
|
|
||||||
{
|
|
||||||
if (group?.isRecording == true)
|
|
||||||
{
|
|
||||||
group.UpdateRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleRawKeyDown(RawKey key)
|
|
||||||
{
|
|
||||||
// 핫키 레코딩 중이면 무시
|
|
||||||
if (itemGroups.Any(g => g?.isRecording == true)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleHotkeys()
|
|
||||||
{
|
|
||||||
foreach (var group in itemGroups)
|
|
||||||
{
|
|
||||||
if (group?.IsTriggered() == true)
|
|
||||||
{
|
|
||||||
ToggleGroup(group);
|
|
||||||
Debug.Log($"[ItemController] 핫키로 그룹 토글: {group.groupName}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Public Methods
|
#region Public Methods
|
||||||
@ -390,24 +220,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
NotifyItemChanged();
|
NotifyItemChanged();
|
||||||
Debug.Log($"[ItemController] 그룹 제거: {removedGroup.groupName}");
|
Debug.Log($"[ItemController] 그룹 제거: {removedGroup.groupName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartRecordingHotkey(int groupIndex)
|
|
||||||
{
|
|
||||||
if (groupIndex < 0 || groupIndex >= itemGroups.Count) return;
|
|
||||||
|
|
||||||
var group = itemGroups[groupIndex];
|
|
||||||
group.StartRecording();
|
|
||||||
Debug.Log($"[ItemController] 핫키 레코딩 시작: {group.groupName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StopRecordingHotkey(int groupIndex)
|
|
||||||
{
|
|
||||||
if (groupIndex < 0 || groupIndex >= itemGroups.Count) return;
|
|
||||||
|
|
||||||
var group = itemGroups[groupIndex];
|
|
||||||
group.StopRecording();
|
|
||||||
Debug.Log($"[ItemController] 핫키 레코딩 완료: {group.groupName} -> {group}");
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region StreamDeck Integration
|
#region StreamDeck Integration
|
||||||
@ -442,8 +254,7 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
{
|
{
|
||||||
index = i,
|
index = i,
|
||||||
name = g.groupName,
|
name = g.groupName,
|
||||||
isActive = g.IsActive(),
|
isActive = g.IsActive()
|
||||||
hotkey = g.ToString()
|
|
||||||
}).ToArray(),
|
}).ToArray(),
|
||||||
current_index = CurrentIndex
|
current_index = CurrentIndex
|
||||||
};
|
};
|
||||||
@ -480,7 +291,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
public int index;
|
public int index;
|
||||||
public string name;
|
public string name;
|
||||||
public bool isActive;
|
public bool isActive;
|
||||||
public string hotkey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
@ -543,18 +353,6 @@ public class ItemController : MonoBehaviour, IController
|
|||||||
case "deactivate_all":
|
case "deactivate_all":
|
||||||
DeactivateAllGroups();
|
DeactivateAllGroups();
|
||||||
break;
|
break;
|
||||||
case "start_recording_hotkey":
|
|
||||||
if (parameters is int recordIndex)
|
|
||||||
{
|
|
||||||
StartRecordingHotkey(recordIndex);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "stop_recording_hotkey":
|
|
||||||
if (parameters is int stopIndex)
|
|
||||||
{
|
|
||||||
StopRecordingHotkey(stopIndex);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
Debug.LogWarning($"[ItemController] 알 수 없는 액션: {actionId}");
|
Debug.LogWarning($"[ItemController] 알 수 없는 액션: {actionId}");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e34e02a9a1a02b84290f3e1f4a015d5c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using KindRetargeting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 아바타 Head 본 콜라이더 자동 생성 및 타겟 관리
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class AvatarHeadManager
|
||||||
|
{
|
||||||
|
[Tooltip("아바타 Head 본에 자동으로 SphereCollider를 생성합니다 (DOF 타겟, 레이캐스트 등에 활용)")]
|
||||||
|
public bool autoCreateHeadColliders = true;
|
||||||
|
|
||||||
|
[Tooltip("Head 콜라이더 반경")]
|
||||||
|
public float headColliderRadius = 0.1f;
|
||||||
|
|
||||||
|
[NonSerialized]
|
||||||
|
private List<Transform> avatarHeadTargets = new List<Transform>();
|
||||||
|
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
|
||||||
|
if (autoCreateHeadColliders)
|
||||||
|
{
|
||||||
|
RefreshAvatarHeadColliders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshAvatarHeadColliders()
|
||||||
|
{
|
||||||
|
avatarHeadTargets.Clear();
|
||||||
|
|
||||||
|
var retargetingScripts = UnityEngine.Object.FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
|
||||||
|
log?.Invoke($"아바타 Head 콜라이더 검색: CustomRetargetingScript {retargetingScripts.Length}개 발견");
|
||||||
|
|
||||||
|
foreach (var script in retargetingScripts)
|
||||||
|
{
|
||||||
|
if (script.targetAnimator == null) continue;
|
||||||
|
|
||||||
|
Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
||||||
|
if (headBone == null) continue;
|
||||||
|
|
||||||
|
if (!headBone.TryGetComponent<SphereCollider>(out _))
|
||||||
|
{
|
||||||
|
var collider = headBone.gameObject.AddComponent<SphereCollider>();
|
||||||
|
collider.radius = headColliderRadius;
|
||||||
|
collider.isTrigger = true;
|
||||||
|
log?.Invoke($"Head 콜라이더 생성: {headBone.name} ({script.targetAnimator.gameObject.name})");
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarHeadTargets.Add(headBone);
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"총 {avatarHeadTargets.Count}개의 아바타 Head 타겟 등록 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Transform> GetAvatarHeadTargets()
|
||||||
|
{
|
||||||
|
return avatarHeadTargets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 093470731ec59174a877abcd20eed026
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
#if MAGICACLOTH2
|
||||||
|
using MagicaCloth2;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MagicaCloth2 시뮬레이션 리셋 관리
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class ClothSimulationManager
|
||||||
|
{
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MAGICACLOTH2
|
||||||
|
public void RefreshAllMagicaCloth(bool keepPose = false)
|
||||||
|
{
|
||||||
|
var allMagicaCloths = UnityEngine.Object.FindObjectsByType<MagicaCloth>(FindObjectsSortMode.None);
|
||||||
|
|
||||||
|
if (allMagicaCloths == null || allMagicaCloths.Length == 0)
|
||||||
|
{
|
||||||
|
log?.Invoke("씬에 MagicaCloth 컴포넌트가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int resetCount = 0;
|
||||||
|
foreach (var cloth in allMagicaCloths)
|
||||||
|
{
|
||||||
|
if (cloth != null && cloth.IsValid())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cloth.ResetCloth(keepPose);
|
||||||
|
resetCount++;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"MagicaCloth 리셋 실패 ({cloth.gameObject.name}): {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"MagicaCloth 시뮬레이션 리셋 완료 ({resetCount}/{allMagicaCloths.Length}개)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAllMagicaCloth()
|
||||||
|
{
|
||||||
|
RefreshAllMagicaCloth(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAllMagicaClothKeepPose()
|
||||||
|
{
|
||||||
|
RefreshAllMagicaCloth(true);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
public void RefreshAllMagicaCloth(bool keepPose = false)
|
||||||
|
{
|
||||||
|
logError?.Invoke("MagicaCloth2가 설치되어 있지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAllMagicaCloth()
|
||||||
|
{
|
||||||
|
logError?.Invoke("MagicaCloth2가 설치되어 있지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAllMagicaClothKeepPose()
|
||||||
|
{
|
||||||
|
logError?.Invoke("MagicaCloth2가 설치되어 있지 않습니다.");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f3fabd42850d64c40914ec3c93a70dbc
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// iFacialMocap/Rokoko 페이셜 모션캡처 관리
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class FacialMotionManager
|
||||||
|
{
|
||||||
|
[Tooltip("true면 씬의 모든 페이셜 모션 클라이언트를 자동으로 찾습니다")]
|
||||||
|
public bool autoFindFacialMotionClients = true;
|
||||||
|
|
||||||
|
[Tooltip("수동으로 지정할 페이셜 모션 클라이언트 목록 (autoFindFacialMotionClients가 false일 때 사용)")]
|
||||||
|
public List<StreamingleFacialReceiver> facialMotionClients = new List<StreamingleFacialReceiver>();
|
||||||
|
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
|
||||||
|
if (autoFindFacialMotionClients)
|
||||||
|
{
|
||||||
|
RefreshFacialMotionClients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshFacialMotionClients()
|
||||||
|
{
|
||||||
|
var allClients = UnityEngine.Object.FindObjectsOfType<StreamingleFacialReceiver>();
|
||||||
|
facialMotionClients = allClients.ToList();
|
||||||
|
log?.Invoke($"Facial Motion 클라이언트 {facialMotionClients.Count}개 발견");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReconnectFacialMotion()
|
||||||
|
{
|
||||||
|
if (autoFindFacialMotionClients)
|
||||||
|
{
|
||||||
|
RefreshFacialMotionClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facialMotionClients == null || facialMotionClients.Count == 0)
|
||||||
|
{
|
||||||
|
logError?.Invoke("Facial Motion 클라이언트가 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"Facial Motion 클라이언트 재접속 시도... ({facialMotionClients.Count}개)");
|
||||||
|
|
||||||
|
int reconnectedCount = 0;
|
||||||
|
foreach (var client in facialMotionClients)
|
||||||
|
{
|
||||||
|
if (client != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client.Reconnect();
|
||||||
|
reconnectedCount++;
|
||||||
|
log?.Invoke($"클라이언트 재접속 성공: {client.gameObject.name}");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"클라이언트 재접속 실패 ({client.gameObject.name}): {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectedCount > 0)
|
||||||
|
{
|
||||||
|
log?.Invoke($"=== Facial Motion 재접속 완료 ({reconnectedCount}/{facialMotionClients.Count}개) ===");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("재접속에 성공한 클라이언트가 없습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9ee0e22a9c0c4b6429a0d7455f411d10
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Entum;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EasyMotion Recorder + OptiTrack Motive 모션 녹화 관리
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class MotionRecordingManager
|
||||||
|
{
|
||||||
|
[Tooltip("true면 씬의 모든 MotionDataRecorder를 자동으로 찾습니다")]
|
||||||
|
public bool autoFindRecorders = true;
|
||||||
|
|
||||||
|
[Tooltip("수동으로 지정할 레코더 목록 (autoFindRecorders가 false일 때 사용)")]
|
||||||
|
public List<MotionDataRecorder> motionRecorders = new List<MotionDataRecorder>();
|
||||||
|
|
||||||
|
[NonSerialized]
|
||||||
|
private bool isRecording = false;
|
||||||
|
|
||||||
|
private OptiTrackManager optiTrackManager;
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(OptiTrackManager optiTrack, Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.optiTrackManager = optiTrack;
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
|
||||||
|
if (autoFindRecorders)
|
||||||
|
{
|
||||||
|
RefreshMotionRecorders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshMotionRecorders()
|
||||||
|
{
|
||||||
|
var allRecorders = UnityEngine.Object.FindObjectsOfType<MotionDataRecorder>();
|
||||||
|
motionRecorders = allRecorders.ToList();
|
||||||
|
log?.Invoke($"Motion Recorder {motionRecorders.Count}개 발견");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartMotionRecording()
|
||||||
|
{
|
||||||
|
bool optitrackStarted = false;
|
||||||
|
if (optiTrackManager != null && optiTrackManager.recordOptiTrackWithMotion)
|
||||||
|
{
|
||||||
|
if (optiTrackManager.optitrackClient != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
optitrackStarted = optiTrackManager.optitrackClient.StartRecording();
|
||||||
|
if (optitrackStarted)
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack Motive 녹화 시작 성공");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptiTrack Motive 녹화 시작 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"OptiTrack 녹화 시작 오류: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 클라이언트 없음 - OptiTrack 녹화 건너뜀");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 녹화 옵션 꺼짐 - OptiTrack 녹화 건너뜀");
|
||||||
|
}
|
||||||
|
|
||||||
|
int startedCount = 0;
|
||||||
|
if (motionRecorders != null && motionRecorders.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var recorder in motionRecorders)
|
||||||
|
{
|
||||||
|
if (recorder != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = recorder.GetType().GetMethod("RecordStart");
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
method.Invoke(recorder, null);
|
||||||
|
startedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"레코더 시작 실패 ({recorder.name}): {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startedCount > 0)
|
||||||
|
{
|
||||||
|
log?.Invoke($"EasyMotion 녹화 시작 ({startedCount}/{motionRecorders.Count}개 레코더)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("녹화를 시작한 EasyMotion 레코더가 없습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("EasyMotion Recorder 없음 - EasyMotion 녹화 건너뜀");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optitrackStarted || startedCount > 0)
|
||||||
|
{
|
||||||
|
isRecording = true;
|
||||||
|
log?.Invoke($"=== 녹화 시작 완료 ===");
|
||||||
|
if (optiTrackManager != null && optiTrackManager.recordOptiTrackWithMotion)
|
||||||
|
{
|
||||||
|
log?.Invoke($"OptiTrack: {(optitrackStarted ? "시작됨" : "시작 안됨")}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke($"OptiTrack: 옵션 꺼짐");
|
||||||
|
}
|
||||||
|
log?.Invoke($"EasyMotion: {startedCount}개 레코더 시작됨");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("녹화를 시작할 수 있는 시스템이 없습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopMotionRecording()
|
||||||
|
{
|
||||||
|
bool optitrackStopped = false;
|
||||||
|
if (optiTrackManager != null && optiTrackManager.recordOptiTrackWithMotion)
|
||||||
|
{
|
||||||
|
if (optiTrackManager.optitrackClient != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
optitrackStopped = optiTrackManager.optitrackClient.StopRecording();
|
||||||
|
if (optitrackStopped)
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack Motive 녹화 중지 성공");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptiTrack Motive 녹화 중지 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"OptiTrack 녹화 중지 오류: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 클라이언트 없음 - OptiTrack 녹화 중지 건너뜀");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 녹화 옵션 꺼짐 - OptiTrack 녹화 중지 건너뜀");
|
||||||
|
}
|
||||||
|
|
||||||
|
int stoppedCount = 0;
|
||||||
|
if (motionRecorders != null && motionRecorders.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var recorder in motionRecorders)
|
||||||
|
{
|
||||||
|
if (recorder != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = recorder.GetType().GetMethod("RecordEnd");
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
method.Invoke(recorder, null);
|
||||||
|
stoppedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"레코더 중지 실패 ({recorder.name}): {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stoppedCount > 0)
|
||||||
|
{
|
||||||
|
log?.Invoke($"EasyMotion 녹화 중지 ({stoppedCount}/{motionRecorders.Count}개 레코더)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("녹화를 중지한 EasyMotion 레코더가 없습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("EasyMotion Recorder 없음 - EasyMotion 녹화 중지 건너뜀");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optitrackStopped || stoppedCount > 0)
|
||||||
|
{
|
||||||
|
isRecording = false;
|
||||||
|
log?.Invoke($"=== 녹화 중지 완료 ===");
|
||||||
|
log?.Invoke($"OptiTrack: {(optitrackStopped ? "중지됨" : "중지 안됨")}");
|
||||||
|
log?.Invoke($"EasyMotion: {stoppedCount}개 레코더 중지됨");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke("녹화를 중지할 수 있는 시스템이 없습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleMotionRecording()
|
||||||
|
{
|
||||||
|
if (isRecording)
|
||||||
|
{
|
||||||
|
StopMotionRecording();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StartMotionRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRecording()
|
||||||
|
{
|
||||||
|
return isRecording;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0f44ada355c002e40a9ed608c59b4d2d
|
||||||
@ -0,0 +1,183 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OptiTrack 모션캡처 클라이언트 관리
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class OptiTrackManager
|
||||||
|
{
|
||||||
|
[Tooltip("OptiTrack 클라이언트")]
|
||||||
|
public OptitrackStreamingClient optitrackClient;
|
||||||
|
|
||||||
|
[Tooltip("OptiTrack 클라이언트가 없을 때 자동으로 프리펩에서 생성할지 여부")]
|
||||||
|
public bool autoSpawnOptitrackClient = true;
|
||||||
|
|
||||||
|
[Tooltip("OptiTrack 클라이언트 프리펩 경로 (Resources 폴더 기준이 아닌 경우 직접 할당 필요)")]
|
||||||
|
public GameObject optitrackClientPrefab;
|
||||||
|
|
||||||
|
[Tooltip("모션 녹화 시 OptiTrack Motive도 함께 녹화할지 여부")]
|
||||||
|
public bool recordOptiTrackWithMotion = true;
|
||||||
|
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
InitializeOptitrackClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeOptitrackClient()
|
||||||
|
{
|
||||||
|
if (optitrackClient != null)
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 클라이언트가 이미 할당되어 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
optitrackClient = UnityEngine.Object.FindObjectOfType<OptitrackStreamingClient>();
|
||||||
|
|
||||||
|
if (optitrackClient != null)
|
||||||
|
{
|
||||||
|
log?.Invoke("씬에서 OptiTrack 클라이언트를 찾았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSpawnOptitrackClient)
|
||||||
|
{
|
||||||
|
SpawnOptitrackClientFromPrefab();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log?.Invoke("OptiTrack 클라이언트가 없습니다. 자동 생성 옵션이 꺼져 있습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SpawnOptitrackClientFromPrefab()
|
||||||
|
{
|
||||||
|
var existingClient = UnityEngine.Object.FindObjectOfType<OptitrackStreamingClient>();
|
||||||
|
if (existingClient != null)
|
||||||
|
{
|
||||||
|
optitrackClient = existingClient;
|
||||||
|
log?.Invoke("씬에 이미 OptiTrack 클라이언트가 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject clientInstance = null;
|
||||||
|
|
||||||
|
if (optitrackClientPrefab != null)
|
||||||
|
{
|
||||||
|
clientInstance = UnityEngine.Object.Instantiate(optitrackClientPrefab);
|
||||||
|
clientInstance.name = "Client - OptiTrack";
|
||||||
|
log?.Invoke("할당된 프리펩에서 OptiTrack 클라이언트 생성됨");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
string prefabPath = "Assets/External/OptiTrack Unity Plugin/OptiTrack/Prefabs/Client - OptiTrack.prefab";
|
||||||
|
var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||||
|
if (prefab != null)
|
||||||
|
{
|
||||||
|
clientInstance = UnityEngine.Object.Instantiate(prefab);
|
||||||
|
clientInstance.name = "Client - OptiTrack";
|
||||||
|
log?.Invoke($"프리펩에서 OptiTrack 클라이언트 생성됨: {prefabPath}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke($"OptiTrack 클라이언트 프리펩을 찾을 수 없습니다: {prefabPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
logError?.Invoke("OptiTrack 클라이언트 프리펩이 할당되지 않았습니다. Inspector에서 프리펩을 할당해주세요.");
|
||||||
|
return;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientInstance != null)
|
||||||
|
{
|
||||||
|
optitrackClient = clientInstance.GetComponent<OptitrackStreamingClient>();
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("생성된 프리펩에 OptitrackStreamingClient 컴포넌트가 없습니다!");
|
||||||
|
UnityEngine.Object.Destroy(clientInstance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleOptitrackMarkers()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptitrackStreamingClient를 찾을 수 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
optitrackClient.ToggleDrawMarkers();
|
||||||
|
log?.Invoke($"OptiTrack 마커 표시: {optitrackClient.DrawMarkers}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowOptitrackMarkers()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptitrackStreamingClient를 찾을 수 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!optitrackClient.DrawMarkers)
|
||||||
|
{
|
||||||
|
optitrackClient.ToggleDrawMarkers();
|
||||||
|
}
|
||||||
|
log?.Invoke("OptiTrack 마커 표시 켜짐");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HideOptitrackMarkers()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptitrackStreamingClient를 찾을 수 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optitrackClient.DrawMarkers)
|
||||||
|
{
|
||||||
|
optitrackClient.ToggleDrawMarkers();
|
||||||
|
}
|
||||||
|
log?.Invoke("OptiTrack 마커 표시 꺼짐");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReconnectOptitrack()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("OptitrackStreamingClient를 찾을 수 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke("OptiTrack 재접속 시도...");
|
||||||
|
optitrackClient.Reconnect();
|
||||||
|
log?.Invoke("OptiTrack 재접속 명령 전송 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOptitrackConnected()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return optitrackClient.IsConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetOptitrackConnectionStatus()
|
||||||
|
{
|
||||||
|
if (optitrackClient == null)
|
||||||
|
{
|
||||||
|
return "OptiTrack 클라이언트 없음";
|
||||||
|
}
|
||||||
|
|
||||||
|
return optitrackClient.GetConnectionStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1d28f8f25a08e074c9d9bdcd24459f45
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
using KindRetargeting;
|
||||||
|
using KindRetargeting.Remote;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 리타게팅 WebSocket 서버 관리
|
||||||
|
/// (HTTP UI는 Streamingle Dashboard에 통합됨)
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class RetargetingRemoteManager
|
||||||
|
{
|
||||||
|
[Tooltip("리타게팅 웹 리모컨 컨트롤러 (없으면 자동 생성)")]
|
||||||
|
public RetargetingRemoteController retargetingRemote;
|
||||||
|
|
||||||
|
[Tooltip("리타게팅 WebSocket 포트")]
|
||||||
|
public int retargetingWsPort = 64212;
|
||||||
|
|
||||||
|
[Tooltip("시작 시 리타게팅 자동 시작")]
|
||||||
|
public bool autoStartRetargetingRemote = true;
|
||||||
|
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
|
||||||
|
InitializeRetargetingRemote();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeRetargetingRemote()
|
||||||
|
{
|
||||||
|
if (retargetingRemote == null)
|
||||||
|
{
|
||||||
|
retargetingRemote = UnityEngine.Object.FindObjectOfType<RetargetingRemoteController>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retargetingRemote == null && autoStartRetargetingRemote)
|
||||||
|
{
|
||||||
|
GameObject remoteObj = new GameObject("RetargetingRemoteController");
|
||||||
|
retargetingRemote = remoteObj.AddComponent<RetargetingRemoteController>();
|
||||||
|
retargetingRemote.AutoStart = false;
|
||||||
|
log?.Invoke("리타게팅 컨트롤러 자동 생성됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retargetingRemote != null)
|
||||||
|
{
|
||||||
|
retargetingRemote.WsPort = retargetingWsPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retargetingRemote != null)
|
||||||
|
{
|
||||||
|
AutoRegisterRetargetingCharacters();
|
||||||
|
|
||||||
|
if (autoStartRetargetingRemote && !retargetingRemote.IsRunning)
|
||||||
|
{
|
||||||
|
retargetingRemote.StartServer();
|
||||||
|
log?.Invoke($"리타게팅 WebSocket 시작됨 - WS: {retargetingWsPort}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AutoRegisterRetargetingCharacters()
|
||||||
|
{
|
||||||
|
if (retargetingRemote == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("리타게팅 컨트롤러가 없습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var characters = UnityEngine.Object.FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
|
||||||
|
foreach (var character in characters)
|
||||||
|
{
|
||||||
|
retargetingRemote.RegisterCharacter(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"리타게팅 캐릭터 {characters.Length}개 등록됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartRetargetingRemote()
|
||||||
|
{
|
||||||
|
if (retargetingRemote == null)
|
||||||
|
{
|
||||||
|
InitializeRetargetingRemote();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retargetingRemote != null && !retargetingRemote.IsRunning)
|
||||||
|
{
|
||||||
|
retargetingRemote.StartServer();
|
||||||
|
log?.Invoke($"리타게팅 WebSocket 시작됨 - WS: {retargetingWsPort}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopRetargetingRemote()
|
||||||
|
{
|
||||||
|
if (retargetingRemote != null && retargetingRemote.IsRunning)
|
||||||
|
{
|
||||||
|
retargetingRemote.StopServer();
|
||||||
|
log?.Invoke("리타게팅 WebSocket 중지됨");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleRetargetingRemote()
|
||||||
|
{
|
||||||
|
if (retargetingRemote == null || !retargetingRemote.IsRunning)
|
||||||
|
{
|
||||||
|
StartRetargetingRemote();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StopRetargetingRemote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void IsRetargetingRemoteRunning(out bool running)
|
||||||
|
{
|
||||||
|
running = retargetingRemote != null && retargetingRemote.IsRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRetargetingRemoteRunning()
|
||||||
|
{
|
||||||
|
return retargetingRemote != null && retargetingRemote.IsRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetRetargetingRemoteUrl()
|
||||||
|
{
|
||||||
|
if (retargetingRemote != null && retargetingRemote.IsRunning)
|
||||||
|
{
|
||||||
|
return $"ws://localhost:{retargetingWsPort}";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7993b7fbc5617d7438308ee2ccf980f6
|
||||||
@ -0,0 +1,583 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 675f8ead0a5faa546b159682fc9c9128
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스크린샷 캡처 관리 (RGB + 알파채널)
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class ScreenshotManager
|
||||||
|
{
|
||||||
|
[Tooltip("스크린샷 해상도 (기본: 4K)")]
|
||||||
|
public int screenshotWidth = 3840;
|
||||||
|
|
||||||
|
[Tooltip("스크린샷 해상도 (기본: 4K)")]
|
||||||
|
public int screenshotHeight = 2160;
|
||||||
|
|
||||||
|
[Tooltip("스크린샷 저장 경로 (비어있으면 바탕화면)")]
|
||||||
|
public string screenshotSavePath = "";
|
||||||
|
|
||||||
|
[Tooltip("파일명 앞에 붙을 접두사")]
|
||||||
|
public string screenshotFilePrefix = "Screenshot";
|
||||||
|
|
||||||
|
[Tooltip("알파 채널 추출용 셰이더")]
|
||||||
|
public Shader alphaShader;
|
||||||
|
|
||||||
|
[Tooltip("NiloToon Prepass 버퍼 텍스처 이름")]
|
||||||
|
public string niloToonPrepassBufferName = "_NiloToonPrepassBufferTex";
|
||||||
|
|
||||||
|
[Tooltip("촬영할 카메라 (비어있으면 메인 카메라 사용)")]
|
||||||
|
public Camera screenshotCamera;
|
||||||
|
|
||||||
|
[Tooltip("알파 채널 블러 반경 (0 = 블러 없음, 1.0 = 약한 블러)")]
|
||||||
|
[Range(0f, 3f)]
|
||||||
|
public float alphaBlurRadius = 1.0f;
|
||||||
|
|
||||||
|
[NonSerialized]
|
||||||
|
private Material alphaMaterial;
|
||||||
|
|
||||||
|
private Action<string> log;
|
||||||
|
private Action<string> logError;
|
||||||
|
|
||||||
|
public void Initialize(Action<string> log, Action<string> logError)
|
||||||
|
{
|
||||||
|
this.log = log;
|
||||||
|
this.logError = logError;
|
||||||
|
|
||||||
|
if (screenshotCamera == null)
|
||||||
|
{
|
||||||
|
screenshotCamera = Camera.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alphaShader == null)
|
||||||
|
{
|
||||||
|
alphaShader = Shader.Find("Hidden/AlphaFromNiloToon");
|
||||||
|
if (alphaShader == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("알파 셰이더를 찾을 수 없습니다: Hidden/AlphaFromNiloToon");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(screenshotSavePath))
|
||||||
|
{
|
||||||
|
screenshotSavePath = Path.Combine(Application.dataPath, "..", "Screenshots");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(screenshotSavePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(screenshotSavePath);
|
||||||
|
log?.Invoke($"Screenshots 폴더 생성됨: {screenshotSavePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureScreenshot()
|
||||||
|
{
|
||||||
|
if (screenshotCamera == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("촬영할 카메라가 설정되지 않았습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = GenerateFileName("png");
|
||||||
|
string fullPath = Path.Combine(screenshotSavePath, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RenderTexture rt = new RenderTexture(screenshotWidth, screenshotHeight, 24);
|
||||||
|
RenderTexture currentRT = screenshotCamera.targetTexture;
|
||||||
|
|
||||||
|
screenshotCamera.targetTexture = rt;
|
||||||
|
screenshotCamera.Render();
|
||||||
|
|
||||||
|
RenderTexture.active = rt;
|
||||||
|
Texture2D screenshot = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGB24, false);
|
||||||
|
screenshot.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0);
|
||||||
|
screenshot.Apply();
|
||||||
|
|
||||||
|
byte[] bytes = screenshot.EncodeToPNG();
|
||||||
|
File.WriteAllBytes(fullPath, bytes);
|
||||||
|
|
||||||
|
screenshotCamera.targetTexture = currentRT;
|
||||||
|
RenderTexture.active = null;
|
||||||
|
rt.Release();
|
||||||
|
UnityEngine.Object.Destroy(rt);
|
||||||
|
UnityEngine.Object.Destroy(screenshot);
|
||||||
|
|
||||||
|
log?.Invoke($"스크린샷 저장 완료: {fullPath}");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"스크린샷 촬영 실패: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureAlphaScreenshot()
|
||||||
|
{
|
||||||
|
if (screenshotCamera == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("촬영할 카메라가 설정되지 않았습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alphaShader == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke("알파 셰이더가 설정되지 않았습니다!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = GenerateFileName("png", "_Alpha");
|
||||||
|
string fullPath = Path.Combine(screenshotSavePath, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RenderTexture rt = new RenderTexture(screenshotWidth, screenshotHeight, 24);
|
||||||
|
RenderTexture currentRT = screenshotCamera.targetTexture;
|
||||||
|
|
||||||
|
screenshotCamera.targetTexture = rt;
|
||||||
|
screenshotCamera.Render();
|
||||||
|
|
||||||
|
Texture niloToonPrepassBuffer = Shader.GetGlobalTexture(niloToonPrepassBufferName);
|
||||||
|
|
||||||
|
if (niloToonPrepassBuffer == null)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"NiloToon Prepass 버퍼를 찾을 수 없습니다: {niloToonPrepassBufferName}");
|
||||||
|
screenshotCamera.targetTexture = currentRT;
|
||||||
|
UnityEngine.Object.Destroy(rt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alphaMaterial == null)
|
||||||
|
{
|
||||||
|
alphaMaterial = new Material(alphaShader);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderTexture alphaRT = new RenderTexture(screenshotWidth, screenshotHeight, 0, RenderTextureFormat.ARGB32);
|
||||||
|
alphaMaterial.SetTexture("_MainTex", rt);
|
||||||
|
alphaMaterial.SetTexture("_AlphaTex", niloToonPrepassBuffer);
|
||||||
|
alphaMaterial.SetFloat("_BlurRadius", alphaBlurRadius);
|
||||||
|
|
||||||
|
Graphics.Blit(rt, alphaRT, alphaMaterial);
|
||||||
|
|
||||||
|
RenderTexture.active = alphaRT;
|
||||||
|
Texture2D screenshot = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGBA32, false);
|
||||||
|
screenshot.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0);
|
||||||
|
screenshot.Apply();
|
||||||
|
|
||||||
|
byte[] bytes = screenshot.EncodeToPNG();
|
||||||
|
File.WriteAllBytes(fullPath, bytes);
|
||||||
|
|
||||||
|
screenshotCamera.targetTexture = currentRT;
|
||||||
|
RenderTexture.active = null;
|
||||||
|
rt.Release();
|
||||||
|
UnityEngine.Object.Destroy(rt);
|
||||||
|
alphaRT.Release();
|
||||||
|
UnityEngine.Object.Destroy(alphaRT);
|
||||||
|
UnityEngine.Object.Destroy(screenshot);
|
||||||
|
|
||||||
|
log?.Invoke($"알파 스크린샷 저장 완료: {fullPath}");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logError?.Invoke($"알파 스크린샷 촬영 실패: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenScreenshotFolder()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(screenshotSavePath))
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(screenshotSavePath);
|
||||||
|
log?.Invoke($"저장 폴더 열기: {screenshotSavePath}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logError?.Invoke($"저장 폴더가 존재하지 않습니다: {screenshotSavePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateFileName(string extension, string suffix = "")
|
||||||
|
{
|
||||||
|
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||||
|
return $"{screenshotFilePrefix}{suffix}_{timestamp}.{extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cleanup()
|
||||||
|
{
|
||||||
|
if (alphaMaterial != null)
|
||||||
|
{
|
||||||
|
UnityEngine.Object.Destroy(alphaMaterial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8b601928d76ee394397d97284c7c2aa5
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,340 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(AvatarOutfitController))]
|
||||||
|
public class AvatarOutfitControllerEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/AvatarOutfitControllerEditor.uxml";
|
||||||
|
private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/AvatarOutfitControllerEditor.uss";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private VisualElement avatarsContainer;
|
||||||
|
private Label avatarsTitleLabel;
|
||||||
|
private AvatarOutfitController controller;
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
controller = (AvatarOutfitController)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
if (uss != null) root.styleSheets.Add(uss);
|
||||||
|
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
BuildAvatarsSection(root);
|
||||||
|
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(200);
|
||||||
|
|
||||||
|
Undo.undoRedoPerformed += OnUndoRedo;
|
||||||
|
root.RegisterCallback<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUndoRedo()
|
||||||
|
{
|
||||||
|
if (controller == null) return;
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Avatars List
|
||||||
|
|
||||||
|
private void BuildAvatarsSection(VisualElement root)
|
||||||
|
{
|
||||||
|
var section = root.Q("avatarsSection");
|
||||||
|
if (section == null) return;
|
||||||
|
|
||||||
|
var header = new VisualElement();
|
||||||
|
header.AddToClassList("list-header");
|
||||||
|
|
||||||
|
avatarsTitleLabel = new Label($"Avatars ({controller.avatars.Count})");
|
||||||
|
avatarsTitleLabel.AddToClassList("list-title");
|
||||||
|
header.Add(avatarsTitleLabel);
|
||||||
|
|
||||||
|
var addBtn = new Button(AddAvatar) { text = "+ 아바타 추가" };
|
||||||
|
addBtn.AddToClassList("list-add-btn");
|
||||||
|
header.Add(addBtn);
|
||||||
|
|
||||||
|
section.Add(header);
|
||||||
|
|
||||||
|
avatarsContainer = new VisualElement();
|
||||||
|
section.Add(avatarsContainer);
|
||||||
|
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildAvatarList()
|
||||||
|
{
|
||||||
|
if (avatarsContainer == null || controller == null) return;
|
||||||
|
avatarsContainer.Clear();
|
||||||
|
|
||||||
|
if (avatarsTitleLabel != null)
|
||||||
|
avatarsTitleLabel.text = $"Avatars ({controller.avatars.Count})";
|
||||||
|
|
||||||
|
if (controller.avatars.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("아바타가 없습니다. '+ 아바타 추가' 버튼을 눌러 추가하세요.");
|
||||||
|
empty.AddToClassList("list-empty");
|
||||||
|
avatarsContainer.Add(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < controller.avatars.Count; i++)
|
||||||
|
{
|
||||||
|
avatarsContainer.Add(CreateAvatarElement(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreateAvatarElement(int avatarIndex)
|
||||||
|
{
|
||||||
|
var avatar = controller.avatars[avatarIndex];
|
||||||
|
bool isCurrent = Application.isPlaying && controller.CurrentAvatar == avatar;
|
||||||
|
|
||||||
|
var item = new VisualElement();
|
||||||
|
item.AddToClassList("list-item");
|
||||||
|
item.AddToClassList("avatar-item");
|
||||||
|
if (isCurrent) item.AddToClassList("list-item--active");
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
var headerRow = new VisualElement();
|
||||||
|
headerRow.AddToClassList("list-item-header");
|
||||||
|
|
||||||
|
var indexLabel = new Label($"{avatarIndex + 1}");
|
||||||
|
indexLabel.AddToClassList("list-index");
|
||||||
|
headerRow.Add(indexLabel);
|
||||||
|
|
||||||
|
var nameField = new TextField();
|
||||||
|
nameField.value = avatar.avatarName;
|
||||||
|
nameField.AddToClassList("list-name-field");
|
||||||
|
int ai = avatarIndex;
|
||||||
|
nameField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Rename Avatar");
|
||||||
|
controller.avatars[ai].avatarName = evt.newValue;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
});
|
||||||
|
headerRow.Add(nameField);
|
||||||
|
|
||||||
|
if (isCurrent)
|
||||||
|
{
|
||||||
|
var activeLabel = new Label("[Current]");
|
||||||
|
activeLabel.AddToClassList("list-active-label");
|
||||||
|
headerRow.Add(activeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder buttons
|
||||||
|
var upBtn = new Button(() => SwapAvatars(ai, ai - 1)) { text = "\u25B2" };
|
||||||
|
upBtn.AddToClassList("list-reorder-btn");
|
||||||
|
upBtn.SetEnabled(avatarIndex > 0);
|
||||||
|
headerRow.Add(upBtn);
|
||||||
|
|
||||||
|
var downBtn = new Button(() => SwapAvatars(ai, ai + 1)) { text = "\u25BC" };
|
||||||
|
downBtn.AddToClassList("list-reorder-btn");
|
||||||
|
downBtn.SetEnabled(avatarIndex < controller.avatars.Count - 1);
|
||||||
|
headerRow.Add(downBtn);
|
||||||
|
|
||||||
|
var deleteBtn = new Button(() => DeleteAvatar(ai)) { text = "X" };
|
||||||
|
deleteBtn.AddToClassList("list-delete-btn");
|
||||||
|
headerRow.Add(deleteBtn);
|
||||||
|
|
||||||
|
item.Add(headerRow);
|
||||||
|
|
||||||
|
// Avatar fields
|
||||||
|
var fields = new VisualElement();
|
||||||
|
fields.AddToClassList("list-fields");
|
||||||
|
|
||||||
|
var listProp = serializedObject.FindProperty("avatars");
|
||||||
|
var avatarProp = listProp.GetArrayElementAtIndex(avatarIndex);
|
||||||
|
|
||||||
|
var avatarObjField = new PropertyField(avatarProp.FindPropertyRelative("avatarObject"), "Avatar Object");
|
||||||
|
fields.Add(avatarObjField);
|
||||||
|
|
||||||
|
// Outfits sub-list
|
||||||
|
int outfitCount = avatar.outfits?.Length ?? 0;
|
||||||
|
var outfitsFoldout = new Foldout { text = $"Outfits ({outfitCount})", value = true };
|
||||||
|
outfitsFoldout.AddToClassList("outfit-foldout");
|
||||||
|
|
||||||
|
var outfitsContainer = new VisualElement();
|
||||||
|
outfitsContainer.AddToClassList("outfits-container");
|
||||||
|
|
||||||
|
if (avatar.outfits != null && avatar.outfits.Length > 0)
|
||||||
|
{
|
||||||
|
var outfitsProp = avatarProp.FindPropertyRelative("outfits");
|
||||||
|
for (int oi = 0; oi < avatar.outfits.Length; oi++)
|
||||||
|
{
|
||||||
|
outfitsContainer.Add(CreateOutfitElement(avatarIndex, oi, outfitsProp.GetArrayElementAtIndex(oi)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var emptyOutfit = new Label("의상이 없습니다.");
|
||||||
|
emptyOutfit.AddToClassList("list-empty");
|
||||||
|
outfitsContainer.Add(emptyOutfit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var addOutfitBtn = new Button(() => AddOutfit(ai)) { text = "+ 의상 추가" };
|
||||||
|
addOutfitBtn.AddToClassList("list-add-btn");
|
||||||
|
addOutfitBtn.style.marginTop = 4;
|
||||||
|
outfitsContainer.Add(addOutfitBtn);
|
||||||
|
|
||||||
|
outfitsFoldout.Add(outfitsContainer);
|
||||||
|
fields.Add(outfitsFoldout);
|
||||||
|
|
||||||
|
item.Add(fields);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreateOutfitElement(int avatarIndex, int outfitIndex, SerializedProperty outfitProp)
|
||||||
|
{
|
||||||
|
var avatar = controller.avatars[avatarIndex];
|
||||||
|
var outfit = avatar.outfits[outfitIndex];
|
||||||
|
bool isCurrentOutfit = avatar.CurrentOutfitIndex == outfitIndex;
|
||||||
|
|
||||||
|
var outfitItem = new VisualElement();
|
||||||
|
outfitItem.AddToClassList("outfit-item");
|
||||||
|
if (isCurrentOutfit) outfitItem.AddToClassList("outfit-item--current");
|
||||||
|
|
||||||
|
// Outfit header
|
||||||
|
var outfitHeader = new VisualElement();
|
||||||
|
outfitHeader.AddToClassList("outfit-header");
|
||||||
|
|
||||||
|
var outfitIdx = new Label($"{outfitIndex + 1}");
|
||||||
|
outfitIdx.AddToClassList("list-index");
|
||||||
|
outfitHeader.Add(outfitIdx);
|
||||||
|
|
||||||
|
var outfitNameField = new TextField();
|
||||||
|
outfitNameField.value = outfit.outfitName;
|
||||||
|
outfitNameField.AddToClassList("list-name-field");
|
||||||
|
int ai = avatarIndex, oi = outfitIndex;
|
||||||
|
outfitNameField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Rename Outfit");
|
||||||
|
controller.avatars[ai].outfits[oi].outfitName = evt.newValue;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
});
|
||||||
|
outfitHeader.Add(outfitNameField);
|
||||||
|
|
||||||
|
if (isCurrentOutfit)
|
||||||
|
{
|
||||||
|
var currentLabel = new Label("[Equipped]");
|
||||||
|
currentLabel.AddToClassList("outfit-current-label");
|
||||||
|
outfitHeader.Add(currentLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteOutfitBtn = new Button(() => DeleteOutfit(ai, oi)) { text = "X" };
|
||||||
|
deleteOutfitBtn.AddToClassList("list-delete-btn");
|
||||||
|
outfitHeader.Add(deleteOutfitBtn);
|
||||||
|
|
||||||
|
outfitItem.Add(outfitHeader);
|
||||||
|
|
||||||
|
// Outfit fields
|
||||||
|
var outfitFields = new VisualElement();
|
||||||
|
outfitFields.AddToClassList("outfit-fields");
|
||||||
|
|
||||||
|
var clothingField = new PropertyField(outfitProp.FindPropertyRelative("clothingObjects"), "Clothing");
|
||||||
|
outfitFields.Add(clothingField);
|
||||||
|
|
||||||
|
var hideField = new PropertyField(outfitProp.FindPropertyRelative("hideObjects"), "Hide Objects");
|
||||||
|
outfitFields.Add(hideField);
|
||||||
|
|
||||||
|
outfitItem.Add(outfitFields);
|
||||||
|
return outfitItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Avatar Actions
|
||||||
|
|
||||||
|
private void AddAvatar()
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Add Avatar");
|
||||||
|
controller.avatars.Add(new AvatarOutfitController.AvatarData("New Avatar"));
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteAvatar(int index)
|
||||||
|
{
|
||||||
|
var avatar = controller.avatars[index];
|
||||||
|
if (EditorUtility.DisplayDialog("아바타 삭제",
|
||||||
|
$"아바타 '{avatar.avatarName}'을(를) 삭제하시겠습니까?", "삭제", "취소"))
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Delete Avatar");
|
||||||
|
controller.avatars.RemoveAt(index);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapAvatars(int a, int b)
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Reorder Avatars");
|
||||||
|
(controller.avatars[a], controller.avatars[b]) = (controller.avatars[b], controller.avatars[a]);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddOutfit(int avatarIndex)
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Add Outfit");
|
||||||
|
var avatar = controller.avatars[avatarIndex];
|
||||||
|
var outfits = avatar.outfits != null
|
||||||
|
? new List<AvatarOutfitController.OutfitData>(avatar.outfits)
|
||||||
|
: new List<AvatarOutfitController.OutfitData>();
|
||||||
|
outfits.Add(new AvatarOutfitController.OutfitData { outfitName = "New Outfit" });
|
||||||
|
avatar.outfits = outfits.ToArray();
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteOutfit(int avatarIndex, int outfitIndex)
|
||||||
|
{
|
||||||
|
var outfit = controller.avatars[avatarIndex].outfits[outfitIndex];
|
||||||
|
if (EditorUtility.DisplayDialog("의상 삭제",
|
||||||
|
$"의상 '{outfit.outfitName}'을(를) 삭제하시겠습니까?", "삭제", "취소"))
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Delete Outfit");
|
||||||
|
var outfits = new List<AvatarOutfitController.OutfitData>(controller.avatars[avatarIndex].outfits);
|
||||||
|
outfits.RemoveAt(outfitIndex);
|
||||||
|
controller.avatars[avatarIndex].outfits = outfits.ToArray();
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildAvatarList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Play Mode State
|
||||||
|
|
||||||
|
private void UpdatePlayModeState()
|
||||||
|
{
|
||||||
|
if (avatarsContainer == null || controller == null) return;
|
||||||
|
if (!Application.isPlaying) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < avatarsContainer.childCount && i < controller.avatars.Count; i++)
|
||||||
|
{
|
||||||
|
var item = avatarsContainer[i];
|
||||||
|
bool isCurrent = controller.CurrentAvatar == controller.avatars[i];
|
||||||
|
|
||||||
|
if (isCurrent && !item.ClassListContains("list-item--active"))
|
||||||
|
item.AddToClassList("list-item--active");
|
||||||
|
else if (!isCurrent && item.ClassListContains("list-item--active"))
|
||||||
|
item.RemoveFromClassList("list-item--active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 559abc9e879f4f740af25a65b7696919
|
||||||
@ -1,562 +1,363 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using UnityRawInput;
|
using UnityEngine.UIElements;
|
||||||
using System.Collections.Generic;
|
using UnityEditor.UIElements;
|
||||||
using System.Linq;
|
|
||||||
using Unity.Cinemachine;
|
using Unity.Cinemachine;
|
||||||
|
|
||||||
[CustomEditor(typeof(CameraManager))]
|
[CustomEditor(typeof(CameraManager))]
|
||||||
public class CameraManagerEditor : Editor
|
public class CameraManagerEditor : Editor
|
||||||
{
|
{
|
||||||
private HashSet<KeyCode> currentKeys = new HashSet<KeyCode>();
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml";
|
||||||
private bool isApplicationPlaying;
|
private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss";
|
||||||
private bool isListening = false;
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
// Foldout 상태
|
private VisualElement presetsContainer;
|
||||||
private bool showCameraPresets = true;
|
private Label presetsTitleLabel;
|
||||||
private bool showControlSettings = true;
|
private CameraManager manager;
|
||||||
private bool showSmoothingSettings = true;
|
|
||||||
private bool showZoomSettings = true;
|
|
||||||
private bool showFOVSettings = true;
|
|
||||||
private bool showRotationTarget = true;
|
|
||||||
private bool showBlendSettings = true;
|
|
||||||
|
|
||||||
// SerializedProperties
|
public override VisualElement CreateInspectorGUI()
|
||||||
private SerializedProperty rotationSensitivityProp;
|
|
||||||
private SerializedProperty panSpeedProp;
|
|
||||||
private SerializedProperty zoomSpeedProp;
|
|
||||||
private SerializedProperty orbitSpeedProp;
|
|
||||||
private SerializedProperty movementSmoothingProp;
|
|
||||||
private SerializedProperty rotationSmoothingProp;
|
|
||||||
private SerializedProperty minZoomDistanceProp;
|
|
||||||
private SerializedProperty maxZoomDistanceProp;
|
|
||||||
private SerializedProperty fovSensitivityProp;
|
|
||||||
private SerializedProperty minFOVProp;
|
|
||||||
private SerializedProperty maxFOVProp;
|
|
||||||
private SerializedProperty useAvatarHeadAsTargetProp;
|
|
||||||
private SerializedProperty manualRotationTargetProp;
|
|
||||||
private SerializedProperty useBlendTransitionProp;
|
|
||||||
private SerializedProperty blendTimeProp;
|
|
||||||
private SerializedProperty useRealtimeBlendProp;
|
|
||||||
|
|
||||||
// 스타일
|
|
||||||
private GUIStyle headerStyle;
|
|
||||||
private GUIStyle sectionBoxStyle;
|
|
||||||
private GUIStyle presetBoxStyle;
|
|
||||||
|
|
||||||
private void OnEnable()
|
|
||||||
{
|
{
|
||||||
isApplicationPlaying = Application.isPlaying;
|
manager = (CameraManager)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
// SerializedProperties 가져오기
|
// Stylesheets
|
||||||
rotationSensitivityProp = serializedObject.FindProperty("rotationSensitivity");
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
panSpeedProp = serializedObject.FindProperty("panSpeed");
|
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||||
zoomSpeedProp = serializedObject.FindProperty("zoomSpeed");
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
orbitSpeedProp = serializedObject.FindProperty("orbitSpeed");
|
if (uss != null) root.styleSheets.Add(uss);
|
||||||
movementSmoothingProp = serializedObject.FindProperty("movementSmoothing");
|
|
||||||
rotationSmoothingProp = serializedObject.FindProperty("rotationSmoothing");
|
// UXML template
|
||||||
minZoomDistanceProp = serializedObject.FindProperty("minZoomDistance");
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
maxZoomDistanceProp = serializedObject.FindProperty("maxZoomDistance");
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
fovSensitivityProp = serializedObject.FindProperty("fovSensitivity");
|
|
||||||
minFOVProp = serializedObject.FindProperty("minFOV");
|
// Conditional UI logic
|
||||||
maxFOVProp = serializedObject.FindProperty("maxFOV");
|
SetupZoomValidation(root);
|
||||||
useAvatarHeadAsTargetProp = serializedObject.FindProperty("useAvatarHeadAsTarget");
|
SetupFOVValidation(root);
|
||||||
manualRotationTargetProp = serializedObject.FindProperty("manualRotationTarget");
|
SetupRotationTargetLogic(root);
|
||||||
useBlendTransitionProp = serializedObject.FindProperty("useBlendTransition");
|
SetupBlendTransitionLogic(root);
|
||||||
blendTimeProp = serializedObject.FindProperty("blendTime");
|
|
||||||
useRealtimeBlendProp = serializedObject.FindProperty("useRealtimeBlend");
|
// Preset list (dynamic)
|
||||||
|
BuildPresetsSection(root);
|
||||||
|
|
||||||
|
// Play mode active state polling
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(200);
|
||||||
|
|
||||||
|
// Undo/redo
|
||||||
|
Undo.undoRedoPerformed += OnUndoRedo;
|
||||||
|
root.RegisterCallback<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
|
||||||
|
|
||||||
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDisable()
|
private void OnUndoRedo()
|
||||||
{
|
{
|
||||||
StopListening();
|
if (manager == null) return;
|
||||||
|
RebuildPresetList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeStyles()
|
#region Validation
|
||||||
|
|
||||||
|
private void SetupZoomValidation(VisualElement root)
|
||||||
{
|
{
|
||||||
if (headerStyle == null)
|
var warning = root.Q<HelpBox>("zoomWarning");
|
||||||
|
if (warning == null) return;
|
||||||
|
|
||||||
|
var minProp = serializedObject.FindProperty("minZoomDistance");
|
||||||
|
var maxProp = serializedObject.FindProperty("maxZoomDistance");
|
||||||
|
|
||||||
|
void UpdateVisibility(SerializedProperty _)
|
||||||
{
|
{
|
||||||
headerStyle = new GUIStyle(EditorStyles.boldLabel)
|
serializedObject.Update();
|
||||||
|
bool show = serializedObject.FindProperty("minZoomDistance").floatValue >=
|
||||||
|
serializedObject.FindProperty("maxZoomDistance").floatValue;
|
||||||
|
warning.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.TrackPropertyValue(minProp, UpdateVisibility);
|
||||||
|
root.TrackPropertyValue(maxProp, UpdateVisibility);
|
||||||
|
UpdateVisibility(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupFOVValidation(VisualElement root)
|
||||||
|
{
|
||||||
|
var warning = root.Q<HelpBox>("fovWarning");
|
||||||
|
if (warning == null) return;
|
||||||
|
|
||||||
|
var minProp = serializedObject.FindProperty("minFOV");
|
||||||
|
var maxProp = serializedObject.FindProperty("maxFOV");
|
||||||
|
|
||||||
|
void UpdateVisibility(SerializedProperty _)
|
||||||
|
{
|
||||||
|
serializedObject.Update();
|
||||||
|
bool show = serializedObject.FindProperty("minFOV").floatValue >=
|
||||||
|
serializedObject.FindProperty("maxFOV").floatValue;
|
||||||
|
warning.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.TrackPropertyValue(minProp, UpdateVisibility);
|
||||||
|
root.TrackPropertyValue(maxProp, UpdateVisibility);
|
||||||
|
UpdateVisibility(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupRotationTargetLogic(VisualElement root)
|
||||||
|
{
|
||||||
|
var targetField = root.Q<PropertyField>("manualRotationTargetField");
|
||||||
|
var info = root.Q<HelpBox>("rotationTargetInfo");
|
||||||
|
if (targetField == null || info == null) return;
|
||||||
|
|
||||||
|
var useHeadProp = serializedObject.FindProperty("useAvatarHeadAsTarget");
|
||||||
|
|
||||||
|
void UpdateState(SerializedProperty _)
|
||||||
|
{
|
||||||
|
serializedObject.Update();
|
||||||
|
bool useHead = serializedObject.FindProperty("useAvatarHeadAsTarget").boolValue;
|
||||||
|
targetField.SetEnabled(!useHead);
|
||||||
|
info.style.display = useHead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.TrackPropertyValue(useHeadProp, UpdateState);
|
||||||
|
UpdateState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupBlendTransitionLogic(VisualElement root)
|
||||||
|
{
|
||||||
|
var settingsGroup = root.Q("blendSettingsGroup");
|
||||||
|
var modeInfo = root.Q<HelpBox>("blendModeInfo");
|
||||||
|
var rendererWarning = root.Q<HelpBox>("blendRendererWarning");
|
||||||
|
if (settingsGroup == null || modeInfo == null || rendererWarning == null) return;
|
||||||
|
|
||||||
|
var useBlendProp = serializedObject.FindProperty("useBlendTransition");
|
||||||
|
var useRealtimeProp = serializedObject.FindProperty("useRealtimeBlend");
|
||||||
|
|
||||||
|
void UpdateState(SerializedProperty _)
|
||||||
|
{
|
||||||
|
serializedObject.Update();
|
||||||
|
bool useBlend = serializedObject.FindProperty("useBlendTransition").boolValue;
|
||||||
|
bool useRealtime = serializedObject.FindProperty("useRealtimeBlend").boolValue;
|
||||||
|
|
||||||
|
settingsGroup.SetEnabled(useBlend);
|
||||||
|
rendererWarning.style.display = useBlend ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
|
||||||
|
if (useBlend)
|
||||||
{
|
{
|
||||||
fontSize = 12,
|
modeInfo.style.display = DisplayStyle.Flex;
|
||||||
margin = new RectOffset(0, 0, 5, 5)
|
modeInfo.text = useRealtime
|
||||||
};
|
? "실시간 모드: 블렌딩 중 두 카메라 모두 실시간으로 렌더링됩니다.\n성능 비용이 증가하지만 이전 카메라도 움직입니다."
|
||||||
}
|
: "스냅샷 모드: 이전 화면을 캡처한 후 블렌딩합니다.\n성능 효율적이지만 이전 화면이 정지합니다.";
|
||||||
|
|
||||||
if (sectionBoxStyle == null)
|
|
||||||
{
|
|
||||||
sectionBoxStyle = new GUIStyle(EditorStyles.helpBox)
|
|
||||||
{
|
|
||||||
padding = new RectOffset(10, 10, 10, 10),
|
|
||||||
margin = new RectOffset(0, 0, 5, 5)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (presetBoxStyle == null)
|
|
||||||
{
|
|
||||||
presetBoxStyle = new GUIStyle(GUI.skin.box)
|
|
||||||
{
|
|
||||||
padding = new RectOffset(8, 8, 8, 8),
|
|
||||||
margin = new RectOffset(0, 0, 3, 3)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartListening()
|
|
||||||
{
|
|
||||||
if (!isApplicationPlaying)
|
|
||||||
{
|
|
||||||
currentKeys.Clear();
|
|
||||||
isListening = true;
|
|
||||||
Debug.Log("키보드 입력 감지 시작");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopListening()
|
|
||||||
{
|
|
||||||
isListening = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnInspectorGUI()
|
|
||||||
{
|
|
||||||
serializedObject.Update();
|
|
||||||
InitializeStyles();
|
|
||||||
|
|
||||||
CameraManager manager = (CameraManager)target;
|
|
||||||
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
|
|
||||||
// 카메라 컨트롤 설정 섹션
|
|
||||||
DrawControlSettingsSection();
|
|
||||||
|
|
||||||
// 스무딩 설정 섹션
|
|
||||||
DrawSmoothingSection();
|
|
||||||
|
|
||||||
// 줌 제한 섹션
|
|
||||||
DrawZoomLimitsSection();
|
|
||||||
|
|
||||||
// FOV 설정 섹션
|
|
||||||
DrawFOVSettingsSection();
|
|
||||||
|
|
||||||
// 회전 타겟 섹션
|
|
||||||
DrawRotationTargetSection();
|
|
||||||
|
|
||||||
// 블렌드 전환 섹션
|
|
||||||
DrawBlendTransitionSection();
|
|
||||||
|
|
||||||
EditorGUILayout.Space(10);
|
|
||||||
|
|
||||||
// 카메라 프리셋 섹션
|
|
||||||
DrawCameraPresetsSection(manager);
|
|
||||||
|
|
||||||
// 키 입력 감지 로직
|
|
||||||
HandleKeyListening(manager);
|
|
||||||
|
|
||||||
serializedObject.ApplyModifiedProperties();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawControlSettingsSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showControlSettings = EditorGUILayout.Foldout(showControlSettings, "Camera Control Settings", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showControlSettings)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(rotationSensitivityProp, new GUIContent("회전 감도", "마우스 회전 감도 (0.5 ~ 10)"));
|
|
||||||
EditorGUILayout.PropertyField(panSpeedProp, new GUIContent("패닝 속도", "휠클릭 패닝 속도 (0.005 ~ 0.1)"));
|
|
||||||
EditorGUILayout.PropertyField(zoomSpeedProp, new GUIContent("줌 속도", "마우스 휠 줌 속도 (0.05 ~ 0.5)"));
|
|
||||||
EditorGUILayout.PropertyField(orbitSpeedProp, new GUIContent("오빗 속도", "궤도 회전 속도 (1 ~ 20)"));
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawSmoothingSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showSmoothingSettings = EditorGUILayout.Foldout(showSmoothingSettings, "Smoothing", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showSmoothingSettings)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(movementSmoothingProp, new GUIContent("이동 스무딩", "카메라 이동 부드러움 (0 ~ 0.95)"));
|
|
||||||
EditorGUILayout.PropertyField(rotationSmoothingProp, new GUIContent("회전 스무딩", "카메라 회전 부드러움 (0 ~ 0.95)"));
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawZoomLimitsSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showZoomSettings = EditorGUILayout.Foldout(showZoomSettings, "Zoom Limits", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showZoomSettings)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(minZoomDistanceProp, new GUIContent("최소 줌 거리", "카메라 최소 거리"));
|
|
||||||
EditorGUILayout.PropertyField(maxZoomDistanceProp, new GUIContent("최대 줌 거리", "카메라 최대 거리"));
|
|
||||||
|
|
||||||
// 경고 표시
|
|
||||||
if (minZoomDistanceProp.floatValue >= maxZoomDistanceProp.floatValue)
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("최소 줌 거리는 최대 줌 거리보다 작아야 합니다.", MessageType.Warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawFOVSettingsSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showFOVSettings = EditorGUILayout.Foldout(showFOVSettings, "FOV Settings", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showFOVSettings)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(fovSensitivityProp, new GUIContent("FOV 감도", "Shift + 드래그 시 FOV 변화 감도 (0.01 ~ 5)"));
|
|
||||||
EditorGUILayout.PropertyField(minFOVProp, new GUIContent("최소 FOV", "카메라 최소 FOV (줌인 한계)"));
|
|
||||||
EditorGUILayout.PropertyField(maxFOVProp, new GUIContent("최대 FOV", "카메라 최대 FOV (줌아웃 한계)"));
|
|
||||||
|
|
||||||
// 경고 표시
|
|
||||||
if (minFOVProp.floatValue >= maxFOVProp.floatValue)
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("최소 FOV는 최대 FOV보다 작아야 합니다.", MessageType.Warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.HelpBox("Shift + 좌클릭/우클릭 드래그로 FOV를 조절합니다.\n위로 밀면 줌인(FOV 감소), 아래로 밀면 줌아웃(FOV 증가)", MessageType.Info);
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawRotationTargetSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showRotationTarget = EditorGUILayout.Foldout(showRotationTarget, "Rotation Target", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showRotationTarget)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(useAvatarHeadAsTargetProp, new GUIContent("아바타 머리 사용", "활성화하면 아바타 머리를 회전 중심으로 사용"));
|
|
||||||
|
|
||||||
EditorGUI.BeginDisabledGroup(useAvatarHeadAsTargetProp.boolValue);
|
|
||||||
EditorGUILayout.PropertyField(manualRotationTargetProp, new GUIContent("수동 회전 타겟", "수동으로 지정하는 회전 중심점"));
|
|
||||||
EditorGUI.EndDisabledGroup();
|
|
||||||
|
|
||||||
if (useAvatarHeadAsTargetProp.boolValue)
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("런타임에 CustomRetargetingScript를 가진 아바타의 Head 본을 자동으로 찾습니다.", MessageType.Info);
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawBlendTransitionSection()
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
showBlendSettings = EditorGUILayout.Foldout(showBlendSettings, "Camera Blend Transition", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
if (showBlendSettings)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
EditorGUI.indentLevel++;
|
|
||||||
|
|
||||||
EditorGUILayout.PropertyField(useBlendTransitionProp, new GUIContent("블렌드 전환 사용", "카메라 전환 시 크로스 디졸브 효과 사용"));
|
|
||||||
|
|
||||||
EditorGUI.BeginDisabledGroup(!useBlendTransitionProp.boolValue);
|
|
||||||
EditorGUILayout.PropertyField(blendTimeProp, new GUIContent("블렌드 시간", "크로스 디졸브 소요 시간 (초)"));
|
|
||||||
EditorGUILayout.PropertyField(useRealtimeBlendProp, new GUIContent("실시간 블렌딩", "두 카메라를 동시에 렌더링하며 블렌딩 (성능 비용 증가)"));
|
|
||||||
EditorGUI.EndDisabledGroup();
|
|
||||||
|
|
||||||
if (useBlendTransitionProp.boolValue)
|
|
||||||
{
|
|
||||||
if (useRealtimeBlendProp.boolValue)
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("실시간 모드: 블렌딩 중 두 카메라 모두 실시간으로 렌더링됩니다.\n성능 비용이 증가하지만 이전 카메라도 움직입니다.", MessageType.Info);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("스냅샷 모드: 이전 화면을 캡처한 후 블렌딩합니다.\n성능 효율적이지만 이전 화면이 정지합니다.", MessageType.Info);
|
|
||||||
}
|
|
||||||
EditorGUILayout.HelpBox("URP Renderer에 CameraBlendRendererFeature가 추가되어 있어야 합니다.", MessageType.Warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUI.indentLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawCameraPresetsSection(CameraManager manager)
|
|
||||||
{
|
|
||||||
EditorGUILayout.BeginVertical(sectionBoxStyle);
|
|
||||||
|
|
||||||
// 헤더
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
|
||||||
showCameraPresets = EditorGUILayout.Foldout(showCameraPresets, $"Camera Presets ({manager.cameraPresets.Count})", true, EditorStyles.foldoutHeader);
|
|
||||||
|
|
||||||
GUILayout.FlexibleSpace();
|
|
||||||
|
|
||||||
// 프리셋 추가 버튼
|
|
||||||
if (GUILayout.Button("+ 프리셋 추가", GUILayout.Width(100), GUILayout.Height(20)))
|
|
||||||
{
|
|
||||||
var newCamera = FindFirstObjectByType<CinemachineCamera>();
|
|
||||||
if (newCamera != null)
|
|
||||||
{
|
|
||||||
manager.cameraPresets.Add(new CameraManager.CameraPreset(newCamera));
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
EditorUtility.DisplayDialog("알림", "Scene에 CinemachineCamera가 없습니다.", "확인");
|
modeInfo.style.display = DisplayStyle.None;
|
||||||
}
|
|
||||||
}
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
if (showCameraPresets)
|
|
||||||
{
|
|
||||||
EditorGUILayout.Space(5);
|
|
||||||
|
|
||||||
if (manager.cameraPresets.Count == 0)
|
|
||||||
{
|
|
||||||
EditorGUILayout.HelpBox("카메라 프리셋이 없습니다. '+ 프리셋 추가' 버튼을 눌러 추가하세요.", MessageType.Info);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 프리셋 리스트
|
|
||||||
for (int i = 0; i < manager.cameraPresets.Count; i++)
|
|
||||||
{
|
|
||||||
DrawPresetItem(manager, i);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
root.TrackPropertyValue(useBlendProp, UpdateState);
|
||||||
|
root.TrackPropertyValue(useRealtimeProp, UpdateState);
|
||||||
|
UpdateState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawPresetItem(CameraManager manager, int index)
|
#endregion
|
||||||
|
|
||||||
|
#region Preset List
|
||||||
|
|
||||||
|
private void BuildPresetsSection(VisualElement root)
|
||||||
|
{
|
||||||
|
var section = root.Q("presetsSection");
|
||||||
|
if (section == null) return;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header = new VisualElement();
|
||||||
|
header.AddToClassList("presets-header");
|
||||||
|
|
||||||
|
presetsTitleLabel = new Label($"Camera Presets ({manager.cameraPresets.Count})");
|
||||||
|
presetsTitleLabel.AddToClassList("presets-title");
|
||||||
|
header.Add(presetsTitleLabel);
|
||||||
|
|
||||||
|
var addBtn = new Button(AddPreset) { text = "+ 프리셋 추가" };
|
||||||
|
addBtn.AddToClassList("preset-add-btn");
|
||||||
|
header.Add(addBtn);
|
||||||
|
|
||||||
|
section.Add(header);
|
||||||
|
|
||||||
|
// Container
|
||||||
|
presetsContainer = new VisualElement();
|
||||||
|
section.Add(presetsContainer);
|
||||||
|
|
||||||
|
RebuildPresetList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildPresetList()
|
||||||
|
{
|
||||||
|
if (presetsContainer == null || manager == null) return;
|
||||||
|
|
||||||
|
presetsContainer.Clear();
|
||||||
|
|
||||||
|
if (presetsTitleLabel != null)
|
||||||
|
presetsTitleLabel.text = $"Camera Presets ({manager.cameraPresets.Count})";
|
||||||
|
|
||||||
|
if (manager.cameraPresets.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("카메라 프리셋이 없습니다. '+ 프리셋 추가' 버튼을 눌러 추가하세요.");
|
||||||
|
empty.AddToClassList("preset-empty");
|
||||||
|
presetsContainer.Add(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < manager.cameraPresets.Count; i++)
|
||||||
|
{
|
||||||
|
presetsContainer.Add(CreatePresetItem(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreatePresetItem(int index)
|
||||||
{
|
{
|
||||||
var preset = manager.cameraPresets[index];
|
var preset = manager.cameraPresets[index];
|
||||||
|
|
||||||
// 활성 프리셋 표시를 위한 배경색
|
|
||||||
bool isActive = Application.isPlaying && manager.CurrentPreset == preset;
|
bool isActive = Application.isPlaying && manager.CurrentPreset == preset;
|
||||||
|
|
||||||
|
var item = new VisualElement();
|
||||||
|
item.AddToClassList("preset-item");
|
||||||
|
if (isActive) item.AddToClassList("preset-item--active");
|
||||||
|
|
||||||
|
// --- Header row ---
|
||||||
|
var headerRow = new VisualElement();
|
||||||
|
headerRow.AddToClassList("preset-item-header");
|
||||||
|
|
||||||
|
var indexLabel = new Label($"{index + 1}");
|
||||||
|
indexLabel.AddToClassList("preset-index");
|
||||||
|
headerRow.Add(indexLabel);
|
||||||
|
|
||||||
|
var nameField = new TextField();
|
||||||
|
nameField.value = preset.presetName;
|
||||||
|
nameField.AddToClassList("preset-name-field");
|
||||||
|
int idx = index;
|
||||||
|
nameField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Rename Camera Preset");
|
||||||
|
manager.cameraPresets[idx].presetName = evt.newValue;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
});
|
||||||
|
headerRow.Add(nameField);
|
||||||
|
|
||||||
if (isActive)
|
if (isActive)
|
||||||
{
|
{
|
||||||
GUI.backgroundColor = new Color(0.5f, 0.8f, 0.5f);
|
var activeLabel = new Label("[Active]");
|
||||||
|
activeLabel.AddToClassList("preset-active-label");
|
||||||
|
headerRow.Add(activeLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorGUILayout.BeginVertical(presetBoxStyle);
|
// Up button
|
||||||
GUI.backgroundColor = Color.white;
|
var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2" };
|
||||||
|
upBtn.AddToClassList("preset-reorder-btn");
|
||||||
|
upBtn.SetEnabled(index > 0);
|
||||||
|
headerRow.Add(upBtn);
|
||||||
|
|
||||||
// 프리셋 헤더
|
// Down button
|
||||||
EditorGUILayout.BeginHorizontal();
|
var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC" };
|
||||||
|
downBtn.AddToClassList("preset-reorder-btn");
|
||||||
|
downBtn.SetEnabled(index < manager.cameraPresets.Count - 1);
|
||||||
|
headerRow.Add(downBtn);
|
||||||
|
|
||||||
// 인덱스 번호
|
// Delete button
|
||||||
GUILayout.Label($"{index + 1}", EditorStyles.boldLabel, GUILayout.Width(20));
|
var deleteBtn = new Button(() => DeletePreset(index)) { text = "X" };
|
||||||
|
deleteBtn.AddToClassList("preset-delete-btn");
|
||||||
|
headerRow.Add(deleteBtn);
|
||||||
|
|
||||||
// 프리셋 이름 필드
|
item.Add(headerRow);
|
||||||
EditorGUI.BeginChangeCheck();
|
|
||||||
preset.presetName = EditorGUILayout.TextField(preset.presetName, GUILayout.MinWidth(100));
|
// --- Property fields ---
|
||||||
if (EditorGUI.EndChangeCheck())
|
var fields = new VisualElement();
|
||||||
|
fields.AddToClassList("preset-fields");
|
||||||
|
|
||||||
|
var cameraField = new ObjectField("Virtual Camera")
|
||||||
{
|
{
|
||||||
|
objectType = typeof(CinemachineCamera),
|
||||||
|
allowSceneObjects = true,
|
||||||
|
value = preset.virtualCamera
|
||||||
|
};
|
||||||
|
int ci = index;
|
||||||
|
cameraField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Change Camera Preset Camera");
|
||||||
|
manager.cameraPresets[ci].virtualCamera = evt.newValue as CinemachineCamera;
|
||||||
EditorUtility.SetDirty(target);
|
EditorUtility.SetDirty(target);
|
||||||
}
|
});
|
||||||
|
fields.Add(cameraField);
|
||||||
|
|
||||||
// 활성 표시
|
var mouseToggle = new Toggle("Allow Mouse Control")
|
||||||
if (isActive)
|
|
||||||
{
|
{
|
||||||
GUILayout.Label("[Active]", EditorStyles.miniLabel, GUILayout.Width(50));
|
tooltip = "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부",
|
||||||
}
|
value = preset.allowMouseControl
|
||||||
|
};
|
||||||
GUILayout.FlexibleSpace();
|
int mi = index;
|
||||||
|
mouseToggle.RegisterValueChangedCallback(evt =>
|
||||||
// 위/아래 버튼
|
|
||||||
EditorGUI.BeginDisabledGroup(index == 0);
|
|
||||||
if (GUILayout.Button("▲", GUILayout.Width(25)))
|
|
||||||
{
|
|
||||||
SwapPresets(manager, index, index - 1);
|
|
||||||
}
|
|
||||||
EditorGUI.EndDisabledGroup();
|
|
||||||
|
|
||||||
EditorGUI.BeginDisabledGroup(index == manager.cameraPresets.Count - 1);
|
|
||||||
if (GUILayout.Button("▼", GUILayout.Width(25)))
|
|
||||||
{
|
|
||||||
SwapPresets(manager, index, index + 1);
|
|
||||||
}
|
|
||||||
EditorGUI.EndDisabledGroup();
|
|
||||||
|
|
||||||
// 삭제 버튼
|
|
||||||
GUI.backgroundColor = new Color(1f, 0.5f, 0.5f);
|
|
||||||
if (GUILayout.Button("X", GUILayout.Width(25)))
|
|
||||||
{
|
|
||||||
if (EditorUtility.DisplayDialog("프리셋 삭제",
|
|
||||||
$"프리셋 '{preset.presetName}'을(를) 삭제하시겠습니까?",
|
|
||||||
"삭제", "취소"))
|
|
||||||
{
|
|
||||||
manager.cameraPresets.RemoveAt(index);
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GUI.backgroundColor = Color.white;
|
|
||||||
|
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
EditorGUILayout.Space(3);
|
|
||||||
|
|
||||||
// 가상 카메라 필드
|
|
||||||
EditorGUI.BeginChangeCheck();
|
|
||||||
preset.virtualCamera = (CinemachineCamera)EditorGUILayout.ObjectField(
|
|
||||||
"Virtual Camera", preset.virtualCamera, typeof(CinemachineCamera), true);
|
|
||||||
if (EditorGUI.EndChangeCheck())
|
|
||||||
{
|
{
|
||||||
|
Undo.RecordObject(target, "Toggle Mouse Control");
|
||||||
|
manager.cameraPresets[mi].allowMouseControl = evt.newValue;
|
||||||
EditorUtility.SetDirty(target);
|
EditorUtility.SetDirty(target);
|
||||||
}
|
});
|
||||||
|
fields.Add(mouseToggle);
|
||||||
|
|
||||||
// 마우스 조작 허용 설정
|
item.Add(fields);
|
||||||
EditorGUI.BeginChangeCheck();
|
return item;
|
||||||
preset.allowMouseControl = EditorGUILayout.Toggle(
|
}
|
||||||
new GUIContent("Allow Mouse Control", "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부"),
|
|
||||||
preset.allowMouseControl);
|
private void AddPreset()
|
||||||
if (EditorGUI.EndChangeCheck())
|
{
|
||||||
|
var newCamera = FindFirstObjectByType<CinemachineCamera>();
|
||||||
|
if (newCamera != null)
|
||||||
{
|
{
|
||||||
|
Undo.RecordObject(target, "Add Camera Preset");
|
||||||
|
manager.cameraPresets.Add(new CameraManager.CameraPreset(newCamera));
|
||||||
EditorUtility.SetDirty(target);
|
EditorUtility.SetDirty(target);
|
||||||
}
|
RebuildPresetList();
|
||||||
|
|
||||||
// 핫키 설정 UI
|
|
||||||
EditorGUILayout.BeginHorizontal();
|
|
||||||
EditorGUILayout.LabelField("Hotkey", GUILayout.Width(70));
|
|
||||||
|
|
||||||
if (preset.hotkey.isRecording)
|
|
||||||
{
|
|
||||||
GUI.backgroundColor = new Color(1f, 0.9f, 0.5f);
|
|
||||||
EditorGUILayout.LabelField("키를 눌렀다 떼면 저장됩니다...", EditorStyles.helpBox);
|
|
||||||
GUI.backgroundColor = Color.white;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 핫키 표시
|
EditorUtility.DisplayDialog("알림", "Scene에 CinemachineCamera가 없습니다.", "확인");
|
||||||
string hotkeyDisplay = preset.hotkey?.ToString() ?? "설정되지 않음";
|
|
||||||
EditorGUILayout.LabelField(hotkeyDisplay, EditorStyles.textField, GUILayout.MinWidth(80));
|
|
||||||
|
|
||||||
if (GUILayout.Button("Record", GUILayout.Width(60)))
|
|
||||||
{
|
|
||||||
foreach (var otherPreset in manager.cameraPresets)
|
|
||||||
{
|
|
||||||
otherPreset.hotkey.isRecording = false;
|
|
||||||
}
|
|
||||||
preset.hotkey.isRecording = true;
|
|
||||||
preset.hotkey.rawKeys.Clear();
|
|
||||||
StartListening();
|
|
||||||
}
|
|
||||||
if (GUILayout.Button("Clear", GUILayout.Width(50)))
|
|
||||||
{
|
|
||||||
preset.hotkey.rawKeys.Clear();
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EditorGUILayout.EndHorizontal();
|
|
||||||
|
|
||||||
EditorGUILayout.EndVertical();
|
|
||||||
EditorGUILayout.Space(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SwapPresets(CameraManager manager, int indexA, int indexB)
|
private void DeletePreset(int index)
|
||||||
{
|
{
|
||||||
var temp = manager.cameraPresets[indexA];
|
var preset = manager.cameraPresets[index];
|
||||||
manager.cameraPresets[indexA] = manager.cameraPresets[indexB];
|
if (EditorUtility.DisplayDialog("프리셋 삭제",
|
||||||
manager.cameraPresets[indexB] = temp;
|
$"프리셋 '{preset.presetName}'을(를) 삭제하시겠습니까?", "삭제", "취소"))
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Delete Camera Preset");
|
||||||
|
manager.cameraPresets.RemoveAt(index);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
RebuildPresetList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapPresets(int a, int b)
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Reorder Camera Presets");
|
||||||
|
(manager.cameraPresets[a], manager.cameraPresets[b]) = (manager.cameraPresets[b], manager.cameraPresets[a]);
|
||||||
EditorUtility.SetDirty(target);
|
EditorUtility.SetDirty(target);
|
||||||
|
RebuildPresetList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleKeyListening(CameraManager manager)
|
#endregion
|
||||||
|
|
||||||
|
#region Play Mode State
|
||||||
|
|
||||||
|
private void UpdatePlayModeState()
|
||||||
{
|
{
|
||||||
if (isListening)
|
if (presetsContainer == null || manager == null) return;
|
||||||
|
if (!Application.isPlaying) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < presetsContainer.childCount && i < manager.cameraPresets.Count; i++)
|
||||||
{
|
{
|
||||||
var e = Event.current;
|
var item = presetsContainer[i];
|
||||||
if (e != null)
|
bool isActive = manager.CurrentPreset == manager.cameraPresets[i];
|
||||||
{
|
|
||||||
if (e.type == EventType.KeyDown && e.keyCode != KeyCode.None)
|
if (isActive && !item.ClassListContains("preset-item--active"))
|
||||||
{
|
item.AddToClassList("preset-item--active");
|
||||||
// 마우스 버튼 제외
|
else if (!isActive && item.ClassListContains("preset-item--active"))
|
||||||
if (e.keyCode != KeyCode.Mouse0 && e.keyCode != KeyCode.Mouse1 && e.keyCode != KeyCode.Mouse2)
|
item.RemoveFromClassList("preset-item--active");
|
||||||
{
|
|
||||||
AddKey(e.keyCode);
|
|
||||||
e.Use();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.type == EventType.KeyUp && currentKeys.Contains(e.keyCode))
|
|
||||||
{
|
|
||||||
var recordingPreset = manager.cameraPresets.FirstOrDefault(p => p.hotkey.isRecording);
|
|
||||||
if (recordingPreset != null)
|
|
||||||
{
|
|
||||||
recordingPreset.hotkey.isRecording = false;
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
StopListening();
|
|
||||||
Repaint();
|
|
||||||
}
|
|
||||||
e.Use();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddKey(KeyCode keyCode)
|
#endregion
|
||||||
{
|
|
||||||
if (!currentKeys.Contains(keyCode))
|
|
||||||
{
|
|
||||||
currentKeys.Add(keyCode);
|
|
||||||
var recordingPreset = ((CameraManager)target).cameraPresets.FirstOrDefault(p => p.hotkey.isRecording);
|
|
||||||
if (recordingPreset != null)
|
|
||||||
{
|
|
||||||
// KeyCode를 RawKey로 변환
|
|
||||||
var rawKeys = new List<RawKey>();
|
|
||||||
foreach (var key in currentKeys)
|
|
||||||
{
|
|
||||||
if (RawKeySetup.KeyMapping.TryGetValue(key, out RawKey rawKey))
|
|
||||||
{
|
|
||||||
rawKeys.Add(rawKey);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"맵핑되지 않은 키: {key}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recordingPreset.hotkey.rawKeys = rawKeys;
|
|
||||||
EditorUtility.SetDirty(target);
|
|
||||||
Repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(EventController))]
|
||||||
|
public class EventControllerEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/EventControllerEditor.uxml";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private VisualElement eventsContainer;
|
||||||
|
private Label eventsTitleLabel;
|
||||||
|
private EventController controller;
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
controller = (EventController)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
BuildEventGroupsSection(root);
|
||||||
|
|
||||||
|
Undo.undoRedoPerformed += OnUndoRedo;
|
||||||
|
root.RegisterCallback<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUndoRedo()
|
||||||
|
{
|
||||||
|
if (controller == null) return;
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildEventList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Event Groups List
|
||||||
|
|
||||||
|
private void BuildEventGroupsSection(VisualElement root)
|
||||||
|
{
|
||||||
|
var section = root.Q("eventGroupsSection");
|
||||||
|
if (section == null) return;
|
||||||
|
|
||||||
|
var header = new VisualElement();
|
||||||
|
header.AddToClassList("list-header");
|
||||||
|
|
||||||
|
eventsTitleLabel = new Label($"Event Groups ({controller.eventGroups.Count})");
|
||||||
|
eventsTitleLabel.AddToClassList("list-title");
|
||||||
|
header.Add(eventsTitleLabel);
|
||||||
|
|
||||||
|
var addBtn = new Button(AddEventGroup) { text = "+ 이벤트 추가" };
|
||||||
|
addBtn.AddToClassList("list-add-btn");
|
||||||
|
header.Add(addBtn);
|
||||||
|
|
||||||
|
section.Add(header);
|
||||||
|
|
||||||
|
eventsContainer = new VisualElement();
|
||||||
|
section.Add(eventsContainer);
|
||||||
|
|
||||||
|
RebuildEventList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildEventList()
|
||||||
|
{
|
||||||
|
if (eventsContainer == null || controller == null) return;
|
||||||
|
eventsContainer.Clear();
|
||||||
|
|
||||||
|
if (eventsTitleLabel != null)
|
||||||
|
eventsTitleLabel.text = $"Event Groups ({controller.eventGroups.Count})";
|
||||||
|
|
||||||
|
if (controller.eventGroups.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("이벤트 그룹이 없습니다. '+ 이벤트 추가' 버튼을 눌러 추가하세요.");
|
||||||
|
empty.AddToClassList("list-empty");
|
||||||
|
eventsContainer.Add(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < controller.eventGroups.Count; i++)
|
||||||
|
{
|
||||||
|
eventsContainer.Add(CreateEventGroupElement(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreateEventGroupElement(int index)
|
||||||
|
{
|
||||||
|
var group = controller.eventGroups[index];
|
||||||
|
|
||||||
|
var item = new VisualElement();
|
||||||
|
item.AddToClassList("list-item");
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
var headerRow = new VisualElement();
|
||||||
|
headerRow.AddToClassList("list-item-header");
|
||||||
|
|
||||||
|
var indexLabel = new Label($"{index + 1}");
|
||||||
|
indexLabel.AddToClassList("list-index");
|
||||||
|
headerRow.Add(indexLabel);
|
||||||
|
|
||||||
|
var nameField = new TextField();
|
||||||
|
nameField.value = group.groupName;
|
||||||
|
nameField.AddToClassList("list-name-field");
|
||||||
|
int idx = index;
|
||||||
|
nameField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Rename Event Group");
|
||||||
|
controller.eventGroups[idx].groupName = evt.newValue;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
});
|
||||||
|
headerRow.Add(nameField);
|
||||||
|
|
||||||
|
// Reorder buttons
|
||||||
|
var upBtn = new Button(() => SwapEvents(idx, idx - 1)) { text = "\u25B2" };
|
||||||
|
upBtn.AddToClassList("list-reorder-btn");
|
||||||
|
upBtn.SetEnabled(index > 0);
|
||||||
|
headerRow.Add(upBtn);
|
||||||
|
|
||||||
|
var downBtn = new Button(() => SwapEvents(idx, idx + 1)) { text = "\u25BC" };
|
||||||
|
downBtn.AddToClassList("list-reorder-btn");
|
||||||
|
downBtn.SetEnabled(index < controller.eventGroups.Count - 1);
|
||||||
|
headerRow.Add(downBtn);
|
||||||
|
|
||||||
|
var deleteBtn = new Button(() => DeleteEvent(idx)) { text = "X" };
|
||||||
|
deleteBtn.AddToClassList("list-delete-btn");
|
||||||
|
headerRow.Add(deleteBtn);
|
||||||
|
|
||||||
|
item.Add(headerRow);
|
||||||
|
|
||||||
|
// Fields - UnityEvent uses PropertyField for the built-in event drawer
|
||||||
|
var fields = new VisualElement();
|
||||||
|
fields.AddToClassList("list-fields");
|
||||||
|
|
||||||
|
var listProp = serializedObject.FindProperty("eventGroups");
|
||||||
|
var elementProp = listProp.GetArrayElementAtIndex(index);
|
||||||
|
|
||||||
|
var eventField = new PropertyField(elementProp.FindPropertyRelative("unityEvent"), "Event");
|
||||||
|
fields.Add(eventField);
|
||||||
|
|
||||||
|
item.Add(fields);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddEventGroup()
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Add Event Group");
|
||||||
|
controller.eventGroups.Add(new EventController.EventGroup("New Event"));
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildEventList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteEvent(int index)
|
||||||
|
{
|
||||||
|
var group = controller.eventGroups[index];
|
||||||
|
if (EditorUtility.DisplayDialog("이벤트 삭제",
|
||||||
|
$"이벤트 '{group.groupName}'을(를) 삭제하시겠습니까?", "삭제", "취소"))
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Delete Event Group");
|
||||||
|
controller.eventGroups.RemoveAt(index);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildEventList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapEvents(int a, int b)
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Reorder Event Groups");
|
||||||
|
(controller.eventGroups[a], controller.eventGroups[b]) = (controller.eventGroups[b], controller.eventGroups[a]);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildEventList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7eb33d4f3e03bc2468850c9caede24c1
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(ItemController))]
|
||||||
|
public class ItemControllerEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/ItemControllerEditor.uxml";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private VisualElement itemsContainer;
|
||||||
|
private Label itemsTitleLabel;
|
||||||
|
private ItemController controller;
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
controller = (ItemController)target;
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
BuildItemGroupsSection(root);
|
||||||
|
|
||||||
|
root.schedule.Execute(UpdatePlayModeState).Every(200);
|
||||||
|
|
||||||
|
Undo.undoRedoPerformed += OnUndoRedo;
|
||||||
|
root.RegisterCallback<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUndoRedo()
|
||||||
|
{
|
||||||
|
if (controller == null) return;
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildItemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Item Groups List
|
||||||
|
|
||||||
|
private void BuildItemGroupsSection(VisualElement root)
|
||||||
|
{
|
||||||
|
var section = root.Q("itemGroupsSection");
|
||||||
|
if (section == null) return;
|
||||||
|
|
||||||
|
var header = new VisualElement();
|
||||||
|
header.AddToClassList("list-header");
|
||||||
|
|
||||||
|
itemsTitleLabel = new Label($"Item Groups ({controller.itemGroups.Count})");
|
||||||
|
itemsTitleLabel.AddToClassList("list-title");
|
||||||
|
header.Add(itemsTitleLabel);
|
||||||
|
|
||||||
|
var addBtn = new Button(AddItemGroup) { text = "+ 그룹 추가" };
|
||||||
|
addBtn.AddToClassList("list-add-btn");
|
||||||
|
header.Add(addBtn);
|
||||||
|
|
||||||
|
section.Add(header);
|
||||||
|
|
||||||
|
itemsContainer = new VisualElement();
|
||||||
|
section.Add(itemsContainer);
|
||||||
|
|
||||||
|
RebuildItemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildItemList()
|
||||||
|
{
|
||||||
|
if (itemsContainer == null || controller == null) return;
|
||||||
|
itemsContainer.Clear();
|
||||||
|
|
||||||
|
if (itemsTitleLabel != null)
|
||||||
|
itemsTitleLabel.text = $"Item Groups ({controller.itemGroups.Count})";
|
||||||
|
|
||||||
|
if (controller.itemGroups.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("아이템 그룹이 없습니다. '+ 그룹 추가' 버튼을 눌러 추가하세요.");
|
||||||
|
empty.AddToClassList("list-empty");
|
||||||
|
itemsContainer.Add(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < controller.itemGroups.Count; i++)
|
||||||
|
{
|
||||||
|
itemsContainer.Add(CreateItemGroupElement(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreateItemGroupElement(int index)
|
||||||
|
{
|
||||||
|
var group = controller.itemGroups[index];
|
||||||
|
bool isActive = Application.isPlaying && group.IsActive();
|
||||||
|
|
||||||
|
var item = new VisualElement();
|
||||||
|
item.AddToClassList("list-item");
|
||||||
|
if (isActive) item.AddToClassList("list-item--active");
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
var headerRow = new VisualElement();
|
||||||
|
headerRow.AddToClassList("list-item-header");
|
||||||
|
|
||||||
|
var indexLabel = new Label($"{index + 1}");
|
||||||
|
indexLabel.AddToClassList("list-index");
|
||||||
|
headerRow.Add(indexLabel);
|
||||||
|
|
||||||
|
var nameField = new TextField();
|
||||||
|
nameField.value = group.groupName;
|
||||||
|
nameField.AddToClassList("list-name-field");
|
||||||
|
int idx = index;
|
||||||
|
nameField.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Rename Item Group");
|
||||||
|
controller.itemGroups[idx].groupName = evt.newValue;
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
});
|
||||||
|
headerRow.Add(nameField);
|
||||||
|
|
||||||
|
if (isActive)
|
||||||
|
{
|
||||||
|
var activeLabel = new Label("[Active]");
|
||||||
|
activeLabel.AddToClassList("list-active-label");
|
||||||
|
headerRow.Add(activeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder buttons
|
||||||
|
var upBtn = new Button(() => SwapItems(idx, idx - 1)) { text = "\u25B2" };
|
||||||
|
upBtn.AddToClassList("list-reorder-btn");
|
||||||
|
upBtn.SetEnabled(index > 0);
|
||||||
|
headerRow.Add(upBtn);
|
||||||
|
|
||||||
|
var downBtn = new Button(() => SwapItems(idx, idx + 1)) { text = "\u25BC" };
|
||||||
|
downBtn.AddToClassList("list-reorder-btn");
|
||||||
|
downBtn.SetEnabled(index < controller.itemGroups.Count - 1);
|
||||||
|
headerRow.Add(downBtn);
|
||||||
|
|
||||||
|
var deleteBtn = new Button(() => DeleteItem(idx)) { text = "X" };
|
||||||
|
deleteBtn.AddToClassList("list-delete-btn");
|
||||||
|
headerRow.Add(deleteBtn);
|
||||||
|
|
||||||
|
item.Add(headerRow);
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
var fields = new VisualElement();
|
||||||
|
fields.AddToClassList("list-fields");
|
||||||
|
|
||||||
|
var listProp = serializedObject.FindProperty("itemGroups");
|
||||||
|
var elementProp = listProp.GetArrayElementAtIndex(index);
|
||||||
|
|
||||||
|
var objectsField = new PropertyField(elementProp.FindPropertyRelative("itemObjects"), "Objects");
|
||||||
|
fields.Add(objectsField);
|
||||||
|
|
||||||
|
item.Add(fields);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddItemGroup()
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Add Item Group");
|
||||||
|
controller.itemGroups.Add(new ItemController.ItemGroup("New Item Group"));
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildItemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteItem(int index)
|
||||||
|
{
|
||||||
|
var group = controller.itemGroups[index];
|
||||||
|
if (EditorUtility.DisplayDialog("그룹 삭제",
|
||||||
|
$"그룹 '{group.groupName}'을(를) 삭제하시겠습니까?", "삭제", "취소"))
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Delete Item Group");
|
||||||
|
controller.itemGroups.RemoveAt(index);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildItemList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapItems(int a, int b)
|
||||||
|
{
|
||||||
|
Undo.RecordObject(target, "Reorder Item Groups");
|
||||||
|
(controller.itemGroups[a], controller.itemGroups[b]) = (controller.itemGroups[b], controller.itemGroups[a]);
|
||||||
|
EditorUtility.SetDirty(target);
|
||||||
|
serializedObject.Update();
|
||||||
|
RebuildItemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Play Mode State
|
||||||
|
|
||||||
|
private void UpdatePlayModeState()
|
||||||
|
{
|
||||||
|
if (itemsContainer == null || controller == null) return;
|
||||||
|
if (!Application.isPlaying) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < itemsContainer.childCount && i < controller.itemGroups.Count; i++)
|
||||||
|
{
|
||||||
|
var item = itemsContainer[i];
|
||||||
|
bool isActive = controller.itemGroups[i].IsActive();
|
||||||
|
|
||||||
|
if (isActive && !item.ClassListContains("list-item--active"))
|
||||||
|
item.AddToClassList("list-item--active");
|
||||||
|
else if (!isActive && item.ClassListContains("list-item--active"))
|
||||||
|
item.RemoveFromClassList("list-item--active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aec9980e487d295439589fb890e0ac3f
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
|
||||||
|
[CustomEditor(typeof(SystemController))]
|
||||||
|
public class SystemControllerEditor : Editor
|
||||||
|
{
|
||||||
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/SystemControllerEditor.uxml";
|
||||||
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
public override VisualElement CreateInspectorGUI()
|
||||||
|
{
|
||||||
|
var root = new VisualElement();
|
||||||
|
|
||||||
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||||
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||||
|
|
||||||
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||||
|
if (uxml != null) uxml.CloneTree(root);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 730652e4327a759459b4f7d26a0acb8f
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4ece65516aefb684caf7a11f9ee5357b
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
/* Avatar Outfit Editor styles */
|
||||||
|
|
||||||
|
.avatar-item {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-foldout {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-foldout > Toggle {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfits-container {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-item {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-item--current {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background-color: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-current-label {
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-fields {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7cdfc0b01b0202a4080deeff78704e25
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- 아바타 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="아바타 설정" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="autoFindAvatars" label="자동 탐색" tooltip="태그로 아바타를 자동 탐색합니다"/>
|
||||||
|
<uie:PropertyField binding-path="avatarTag" label="아바타 태그" tooltip="자동 탐색에 사용할 태그"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Avatars (built dynamically in C#) -->
|
||||||
|
<ui:VisualElement name="avatarsSection" class="section list-section"/>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d0f81967be3194245b71b598816fb72d
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
/* Camera Manager Editor styles */
|
||||||
|
|
||||||
|
.presets-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-header {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-title {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-add-btn {
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 0;
|
||||||
|
padding: 3px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-add-btn:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset Item */
|
||||||
|
.preset-item {
|
||||||
|
background-color: rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-item--active {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background-color: rgba(34, 197, 94, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-item-header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-index {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 20px;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-name-field {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-active-label {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-reorder-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-width: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 10px;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-reorder-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-reorder-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-delete-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-width: 0;
|
||||||
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 11px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-delete-btn:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-fields {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-empty {
|
||||||
|
padding: 12px;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
-unity-font-style: italic;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9f300e94d6d0fd84e98b7017c6515182
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- 카메라 조작 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="카메라 조작 설정" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="rotationSensitivity" label="회전 감도" tooltip="마우스 회전 감도 (0.5 ~ 10)"/>
|
||||||
|
<uie:PropertyField binding-path="panSpeed" label="패닝 속도" tooltip="휠클릭 패닝 속도 (0.005 ~ 0.1)"/>
|
||||||
|
<uie:PropertyField binding-path="zoomSpeed" label="줌 속도" tooltip="마우스 휠 줌 속도 (0.05 ~ 0.5)"/>
|
||||||
|
<uie:PropertyField binding-path="orbitSpeed" label="오빗 속도" tooltip="궤도 회전 속도 (1 ~ 20)"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 스무딩 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="스무딩" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="movementSmoothing" label="이동 스무딩" tooltip="카메라 이동 부드러움 (0 ~ 0.95)"/>
|
||||||
|
<uie:PropertyField binding-path="rotationSmoothing" label="회전 스무딩" tooltip="카메라 회전 부드러움 (0 ~ 0.95)"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 줌 제한 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="줌 제한" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="minZoomDistance" label="최소 줌 거리" tooltip="카메라 최소 거리"/>
|
||||||
|
<uie:PropertyField binding-path="maxZoomDistance" label="최대 줌 거리" tooltip="카메라 최대 거리"/>
|
||||||
|
<ui:HelpBox name="zoomWarning" message-type="Warning" text="최소 줌 거리는 최대 줌 거리보다 작아야 합니다."/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- FOV 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="FOV 설정" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="fovSensitivity" label="FOV 감도" tooltip="Shift + 드래그 시 FOV 변화 감도 (0.01 ~ 5)"/>
|
||||||
|
<uie:PropertyField binding-path="minFOV" label="최소 FOV" tooltip="카메라 최소 FOV (줌인 한계)"/>
|
||||||
|
<uie:PropertyField binding-path="maxFOV" label="최대 FOV" tooltip="카메라 최대 FOV (줌아웃 한계)"/>
|
||||||
|
<ui:HelpBox name="fovWarning" message-type="Warning" text="최소 FOV는 최대 FOV보다 작아야 합니다."/>
|
||||||
|
<ui:HelpBox message-type="Info" text="Shift + 좌클릭/우클릭 드래그로 FOV를 조절합니다. 위로 밀면 줌인(FOV 감소), 아래로 밀면 줌아웃(FOV 증가)"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 회전 타겟 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="회전 타겟" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="useAvatarHeadAsTarget" label="아바타 머리 사용" tooltip="활성화하면 아바타 머리를 회전 중심으로 사용"/>
|
||||||
|
<uie:PropertyField binding-path="manualRotationTarget" label="수동 회전 타겟" tooltip="수동으로 지정하는 회전 중심점" name="manualRotationTargetField"/>
|
||||||
|
<ui:HelpBox name="rotationTargetInfo" message-type="Info" text="런타임에 CustomRetargetingScript를 가진 아바타의 Head 본을 자동으로 찾습니다."/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 카메라 블렌드 전환 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="카메라 블렌드 전환" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="useBlendTransition" label="블렌드 전환 사용" tooltip="카메라 전환 시 크로스 디졸브 효과 사용"/>
|
||||||
|
<ui:VisualElement name="blendSettingsGroup">
|
||||||
|
<uie:PropertyField binding-path="blendTime" label="블렌드 시간" tooltip="크로스 디졸브 소요 시간 (초)"/>
|
||||||
|
<uie:PropertyField binding-path="useRealtimeBlend" label="실시간 블렌딩" tooltip="두 카메라를 동시에 렌더링하며 블렌딩 (성능 비용 증가)"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:HelpBox name="blendModeInfo" message-type="Info"/>
|
||||||
|
<ui:HelpBox name="blendRendererWarning" message-type="Warning" text="URP Renderer에 CameraBlendRendererFeature가 추가되어 있어야 합니다."/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- 카메라 프리셋 (C#에서 동적 생성) -->
|
||||||
|
<ui:VisualElement name="presetsSection" class="section presets-section"/>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 601152077f9f1bd40ac515a1e36aae35
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- 이벤트 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="이벤트 설정" value="false" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="autoFindEvents" label="자동 탐색" tooltip="태그로 이벤트를 자동 탐색합니다 (기본 비활성화)"/>
|
||||||
|
<ui:HelpBox message-type="Info" text="이벤트는 수동으로 추가해야 합니다. 자동 탐색은 지원되지 않습니다."/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Event Groups (built dynamically in C#) -->
|
||||||
|
<ui:VisualElement name="eventGroupsSection" class="section list-section"/>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 589b18a6eea2c5e4191e6aa4e624b541
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
|
||||||
|
|
||||||
|
<!-- 아이템 설정 -->
|
||||||
|
<ui:VisualElement class="section">
|
||||||
|
<ui:Foldout text="아이템 설정" value="true" class="section-foldout">
|
||||||
|
<uie:PropertyField binding-path="autoFindGroups" label="자동 탐색" tooltip="태그로 아이템 그룹을 자동 탐색합니다"/>
|
||||||
|
<uie:PropertyField binding-path="groupTag" label="그룹 태그" tooltip="자동 탐색에 사용할 태그"/>
|
||||||
|
</ui:Foldout>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Item Groups (built dynamically in C#) -->
|
||||||
|
<ui:VisualElement name="itemGroupsSection" class="section list-section"/>
|
||||||
|
|
||||||
|
</ui:UXML>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user