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 UnityEditor;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditor.UIElements;
|
||||
|
||||
[CustomEditor(typeof(OptitrackStreamingClient))]
|
||||
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 그리기
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("OptiTrack 연결 제어", EditorStyles.boldLabel);
|
||||
|
||||
OptitrackStreamingClient client = (OptitrackStreamingClient)target;
|
||||
|
||||
// 연결 상태 표시
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("연결 상태:", GUILayout.Width(80));
|
||||
|
||||
string connectionStatus = Application.isPlaying ? client.GetConnectionStatus() : "게임 실행 중이 아님";
|
||||
Color originalColor = GUI.color;
|
||||
|
||||
if (Application.isPlaying)
|
||||
client = (OptitrackStreamingClient)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
|
||||
statusDot = root.Q("statusDot");
|
||||
statusText = root.Q<Label>("statusText");
|
||||
runtimeOffline = root.Q("runtimeOffline");
|
||||
runtimeOnline = root.Q("runtimeOnline");
|
||||
runtimeInfo = root.Q("runtimeInfo");
|
||||
|
||||
// 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())
|
||||
{
|
||||
GUI.color = Color.green;
|
||||
}
|
||||
else
|
||||
{
|
||||
GUI.color = Color.red;
|
||||
}
|
||||
if (!runtimeOnline.ClassListContains("opti-runtime-online--visible"))
|
||||
runtimeOnline.AddToClassList("opti-runtime-online--visible");
|
||||
if (!runtimeOffline.ClassListContains("opti-runtime-offline--hidden"))
|
||||
runtimeOffline.AddToClassList("opti-runtime-offline--hidden");
|
||||
}
|
||||
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);
|
||||
GUI.color = originalColor;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space();
|
||||
|
||||
// 재접속 버튼
|
||||
EditorGUI.BeginDisabledGroup(!Application.isPlaying);
|
||||
|
||||
if (GUILayout.Button("OptiTrack 재접속", GUILayout.Height(30)))
|
||||
|
||||
// Update status badge
|
||||
if (isPlaying)
|
||||
{
|
||||
client.Reconnect();
|
||||
bool connected = client.IsConnected();
|
||||
string status = client.GetConnectionStatus();
|
||||
|
||||
SetStatusStyle(connected);
|
||||
if (statusText != null)
|
||||
statusText.text = status;
|
||||
|
||||
RebuildRuntimeInfo();
|
||||
}
|
||||
|
||||
EditorGUI.EndDisabledGroup();
|
||||
|
||||
if (!Application.isPlaying)
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("재접속 기능은 게임이 실행 중일 때만 사용할 수 있습니다.", MessageType.Info);
|
||||
}
|
||||
|
||||
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();
|
||||
SetStatusStyle(null);
|
||||
if (statusText != null)
|
||||
statusText.text = "Stopped";
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
guid: 53a33bd699913a642832d9cefe527f21
|
||||
guid: a2ec7617f65956541ad157e05b2c005b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
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 UnityEditor;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditor.UIElements;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[CustomPropertyDrawer(typeof(StreamingleFacialReceiver.BlendShapeIntensityOverride))]
|
||||
public class BlendShapeIntensityOverrideDrawer : PropertyDrawer
|
||||
{
|
||||
// 카테고리별로 구분된 ARKit BlendShape 이름 (Popup에 구분선 역할)
|
||||
private static readonly string[] ARKitBlendShapeNames = new string[]
|
||||
{
|
||||
// Eye (0-13)
|
||||
"EyeBlinkLeft", "EyeBlinkRight",
|
||||
"EyeLookDownLeft", "EyeLookDownRight",
|
||||
"EyeLookInLeft", "EyeLookInRight",
|
||||
"EyeLookOutLeft", "EyeLookOutRight",
|
||||
"EyeLookUpLeft", "EyeLookUpRight",
|
||||
"EyeSquintLeft", "EyeSquintRight",
|
||||
"EyeWideLeft", "EyeWideRight",
|
||||
// Jaw (14-17)
|
||||
"JawForward", "JawLeft", "JawRight", "JawOpen",
|
||||
// Mouth (18-37)
|
||||
"MouthClose", "MouthFunnel", "MouthPucker",
|
||||
"MouthLeft", "MouthRight",
|
||||
"MouthSmileLeft", "MouthSmileRight",
|
||||
"MouthFrownLeft", "MouthFrownRight",
|
||||
"MouthDimpleLeft", "MouthDimpleRight",
|
||||
"MouthStretchLeft", "MouthStretchRight",
|
||||
"MouthRollLower", "MouthRollUpper",
|
||||
"MouthShrugLower", "MouthShrugUpper",
|
||||
"MouthPressLeft", "MouthPressRight",
|
||||
"MouthLowerDownLeft", "MouthLowerDownRight",
|
||||
"MouthUpperUpLeft", "MouthUpperUpRight",
|
||||
// Brow (38-42)
|
||||
"BrowDownLeft", "BrowDownRight",
|
||||
"BrowInnerUp",
|
||||
"BrowOuterUpLeft", "BrowOuterUpRight",
|
||||
// Cheek/Nose (43-47)
|
||||
"CheekPuff", "CheekSquintLeft", "CheekSquintRight",
|
||||
"NoseSneerLeft", "NoseSneerRight",
|
||||
// Tongue (48)
|
||||
"TongueOut",
|
||||
};
|
||||
private static readonly string[] ARKitBlendShapeNames = new string[]
|
||||
{
|
||||
// Eye
|
||||
"EyeBlinkLeft", "EyeBlinkRight",
|
||||
"EyeLookDownLeft", "EyeLookDownRight",
|
||||
"EyeLookInLeft", "EyeLookInRight",
|
||||
"EyeLookOutLeft", "EyeLookOutRight",
|
||||
"EyeLookUpLeft", "EyeLookUpRight",
|
||||
"EyeSquintLeft", "EyeSquintRight",
|
||||
"EyeWideLeft", "EyeWideRight",
|
||||
// Jaw
|
||||
"JawForward", "JawLeft", "JawRight", "JawOpen",
|
||||
// Mouth
|
||||
"MouthClose", "MouthFunnel", "MouthPucker",
|
||||
"MouthLeft", "MouthRight",
|
||||
"MouthSmileLeft", "MouthSmileRight",
|
||||
"MouthFrownLeft", "MouthFrownRight",
|
||||
"MouthDimpleLeft", "MouthDimpleRight",
|
||||
"MouthStretchLeft", "MouthStretchRight",
|
||||
"MouthRollLower", "MouthRollUpper",
|
||||
"MouthShrugLower", "MouthShrugUpper",
|
||||
"MouthPressLeft", "MouthPressRight",
|
||||
"MouthLowerDownLeft", "MouthLowerDownRight",
|
||||
"MouthUpperUpLeft", "MouthUpperUpRight",
|
||||
// Brow
|
||||
"BrowDownLeft", "BrowDownRight",
|
||||
"BrowInnerUp",
|
||||
"BrowOuterUpLeft", "BrowOuterUpRight",
|
||||
// Cheek/Nose
|
||||
"CheekPuff", "CheekSquintLeft", "CheekSquintRight",
|
||||
"NoseSneerLeft", "NoseSneerRight",
|
||||
// Tongue
|
||||
"TongueOut",
|
||||
};
|
||||
|
||||
// 카테고리 구분 표시용
|
||||
private static readonly string[] DisplayNames;
|
||||
private static readonly Dictionary<string, int> nameToIndex;
|
||||
private static readonly List<string> DisplayNames;
|
||||
private static readonly Dictionary<string, int> NameToIndex;
|
||||
|
||||
static BlendShapeIntensityOverrideDrawer()
|
||||
{
|
||||
nameToIndex = new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
||||
static BlendShapeIntensityOverrideDrawer()
|
||||
{
|
||||
NameToIndex = new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
||||
DisplayNames = new List<string>(ARKitBlendShapeNames.Length);
|
||||
|
||||
// 카테고리 프리픽스 부여
|
||||
DisplayNames = new string[ARKitBlendShapeNames.Length];
|
||||
for (int i = 0; i < ARKitBlendShapeNames.Length; i++)
|
||||
{
|
||||
string name = ARKitBlendShapeNames[i];
|
||||
string category;
|
||||
for (int i = 0; i < ARKitBlendShapeNames.Length; i++)
|
||||
{
|
||||
string name = ARKitBlendShapeNames[i];
|
||||
string category;
|
||||
|
||||
if (name.StartsWith("Eye")) category = "Eye";
|
||||
else if (name.StartsWith("Jaw")) category = "Jaw";
|
||||
else if (name.StartsWith("Mouth")) category = "Mouth";
|
||||
else if (name.StartsWith("Brow")) category = "Brow";
|
||||
else if (name.StartsWith("Cheek") || name.StartsWith("Nose")) category = "Cheek-Nose";
|
||||
else if (name.StartsWith("Tongue")) category = "Tongue";
|
||||
else category = "";
|
||||
if (name.StartsWith("Eye")) category = "Eye";
|
||||
else if (name.StartsWith("Jaw")) category = "Jaw";
|
||||
else if (name.StartsWith("Mouth")) category = "Mouth";
|
||||
else if (name.StartsWith("Brow")) category = "Brow";
|
||||
else if (name.StartsWith("Cheek") || name.StartsWith("Nose")) category = "Cheek-Nose";
|
||||
else if (name.StartsWith("Tongue")) category = "Tongue";
|
||||
else category = "";
|
||||
|
||||
DisplayNames[i] = category + "/" + name;
|
||||
nameToIndex[name] = i;
|
||||
}
|
||||
}
|
||||
DisplayNames.Add(category + "/" + name);
|
||||
NameToIndex[name] = i;
|
||||
}
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||
{
|
||||
return EditorGUIUtility.singleLineHeight + 2f;
|
||||
}
|
||||
private const string UssPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss";
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
public override VisualElement CreatePropertyGUI(SerializedProperty property)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.AddToClassList("blendshape-override-row");
|
||||
|
||||
position.y += 1f;
|
||||
position.height -= 2f;
|
||||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (uss != null) row.styleSheets.Add(uss);
|
||||
|
||||
var nameProp = property.FindPropertyRelative("blendShapeName");
|
||||
var intensityProp = property.FindPropertyRelative("intensity");
|
||||
var nameProp = property.FindPropertyRelative("blendShapeName");
|
||||
var intensityProp = property.FindPropertyRelative("intensity");
|
||||
|
||||
// 새로 추가된 항목의 기본값 보정
|
||||
if (intensityProp.floatValue == 0f && string.IsNullOrEmpty(nameProp.stringValue))
|
||||
{
|
||||
intensityProp.floatValue = 1.0f;
|
||||
}
|
||||
// Default value for new entries
|
||||
if (intensityProp.floatValue == 0f && string.IsNullOrEmpty(nameProp.stringValue))
|
||||
{
|
||||
intensityProp.floatValue = 1.0f;
|
||||
nameProp.stringValue = ARKitBlendShapeNames[0];
|
||||
property.serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
|
||||
// 레이아웃: [드롭다운 55%] [슬라이더 35%] [값 라벨 10%]
|
||||
float dropW = position.width * 0.55f;
|
||||
float sliderW = position.width * 0.35f;
|
||||
float valW = position.width * 0.10f - 6f;
|
||||
// Dropdown for ARKit blendshape selection
|
||||
int currentIndex = 0;
|
||||
if (!string.IsNullOrEmpty(nameProp.stringValue) && NameToIndex.TryGetValue(nameProp.stringValue, out int idx))
|
||||
currentIndex = idx;
|
||||
|
||||
Rect dropRect = new Rect(position.x, position.y, dropW - 2f, position.height);
|
||||
Rect sliderRect = new Rect(position.x + dropW + 2f, position.y, sliderW - 2f, position.height);
|
||||
Rect valRect = new Rect(position.x + dropW + sliderW + 4f, position.y, valW, position.height);
|
||||
var dropdown = new PopupField<string>(DisplayNames, currentIndex);
|
||||
dropdown.AddToClassList("blendshape-override-dropdown");
|
||||
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);
|
||||
|
||||
// 현재 인덱스
|
||||
int currentIndex = 0;
|
||||
if (!string.IsNullOrEmpty(nameProp.stringValue) && nameToIndex.TryGetValue(nameProp.stringValue, out int idx))
|
||||
{
|
||||
currentIndex = idx;
|
||||
}
|
||||
// Slider for intensity
|
||||
var slider = new Slider(0f, 3f);
|
||||
slider.value = intensityProp.floatValue;
|
||||
slider.AddToClassList("blendshape-override-slider");
|
||||
row.Add(slider);
|
||||
|
||||
// 드롭다운 (카테고리 구분)
|
||||
int newIndex = EditorGUI.Popup(dropRect, currentIndex, DisplayNames);
|
||||
if (newIndex != currentIndex || string.IsNullOrEmpty(nameProp.stringValue))
|
||||
{
|
||||
nameProp.stringValue = ARKitBlendShapeNames[newIndex];
|
||||
}
|
||||
// Value label with color coding
|
||||
var valueLabel = new Label($"x{intensityProp.floatValue:F1}");
|
||||
valueLabel.AddToClassList("blendshape-override-value");
|
||||
UpdateValueLabelStyle(valueLabel, intensityProp.floatValue);
|
||||
row.Add(valueLabel);
|
||||
|
||||
// 슬라이더
|
||||
intensityProp.floatValue = GUI.HorizontalSlider(sliderRect, intensityProp.floatValue, 0f, 3f);
|
||||
// Bind slider <-> property
|
||||
slider.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
intensityProp.floatValue = evt.newValue;
|
||||
intensityProp.serializedObject.ApplyModifiedProperties();
|
||||
valueLabel.text = $"x{evt.newValue:F1}";
|
||||
UpdateValueLabelStyle(valueLabel, evt.newValue);
|
||||
});
|
||||
|
||||
// 값 표시 (색상으로 강약 표현)
|
||||
float val = intensityProp.floatValue;
|
||||
Color valColor;
|
||||
if (val < 0.5f) valColor = new Color(1f, 0.4f, 0.4f); // 약함 = 빨강
|
||||
else if (val > 1.5f) valColor = new Color(0.4f, 0.8f, 1f); // 강함 = 파랑
|
||||
else valColor = new Color(0.7f, 0.7f, 0.7f); // 보통 = 회색
|
||||
// Track external changes to intensity
|
||||
row.TrackPropertyValue(intensityProp, prop =>
|
||||
{
|
||||
slider.SetValueWithoutNotify(prop.floatValue);
|
||||
valueLabel.text = $"x{prop.floatValue:F1}";
|
||||
UpdateValueLabelStyle(valueLabel, prop.floatValue);
|
||||
});
|
||||
|
||||
var valStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
alignment = TextAnchor.MiddleRight,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
valStyle.normal.textColor = valColor;
|
||||
EditorGUI.LabelField(valRect, $"x{val:F1}", valStyle);
|
||||
// Track external changes to blendShapeName
|
||||
row.TrackPropertyValue(nameProp, prop =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(prop.stringValue) && NameToIndex.TryGetValue(prop.stringValue, out int newIdx))
|
||||
{
|
||||
dropdown.SetValueWithoutNotify(DisplayNames[newIdx]);
|
||||
}
|
||||
});
|
||||
|
||||
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 UnityEditor;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditor.UIElements;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[CustomEditor(typeof(StreamingleFacialReceiver))]
|
||||
public class StreamingleFacialReceiverEditor : Editor
|
||||
{
|
||||
private SerializedProperty mirrorMode;
|
||||
private SerializedProperty faceMeshRenderers;
|
||||
private SerializedProperty availablePorts;
|
||||
private const string UxmlPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uxml";
|
||||
private const string UssPath = "Assets/External/StreamingleFacial/Editor/UXML/StreamingleFacialReceiverEditor.uss";
|
||||
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||
|
||||
private SerializedProperty enableFiltering;
|
||||
private SerializedProperty smoothingFactor;
|
||||
private SerializedProperty maxBlendShapeDelta;
|
||||
private SerializedProperty maxRotationDelta;
|
||||
private SerializedProperty fastBlendShapeMultiplier;
|
||||
private SerializedProperty spikeToleranceFrames;
|
||||
private SerializedProperty globalIntensity;
|
||||
private SerializedProperty blendShapeIntensityOverrides;
|
||||
private StreamingleFacialReceiver receiver;
|
||||
private VisualElement portButtonsContainer;
|
||||
private Label activePortValue;
|
||||
private VisualElement statusContainer;
|
||||
private VisualElement filteringFields;
|
||||
|
||||
private bool showConnection = false;
|
||||
private bool showFiltering = false;
|
||||
private bool showIntensity = false;
|
||||
public override VisualElement CreateInspectorGUI()
|
||||
{
|
||||
receiver = (StreamingleFacialReceiver)target;
|
||||
var root = new VisualElement();
|
||||
|
||||
private static readonly Color HeaderColor = new Color(0.18f, 0.18f, 0.18f, 1f);
|
||||
private static readonly Color ActivePortColor = new Color(0.2f, 0.8f, 0.4f, 1f);
|
||||
private static readonly Color InactivePortColor = new Color(0.35f, 0.35f, 0.35f, 1f);
|
||||
private static readonly Color AccentColor = new Color(0.4f, 0.7f, 1f, 1f);
|
||||
// Load stylesheets
|
||||
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
||||
if (commonUss != null) root.styleSheets.Add(commonUss);
|
||||
|
||||
private GUIStyle _headerStyle;
|
||||
private GUIStyle _sectionBoxStyle;
|
||||
private GUIStyle _portButtonStyle;
|
||||
private GUIStyle _portButtonActiveStyle;
|
||||
private GUIStyle _statusLabelStyle;
|
||||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (uss != null) root.styleSheets.Add(uss);
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
mirrorMode = serializedObject.FindProperty("mirrorMode");
|
||||
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");
|
||||
}
|
||||
// Load UXML
|
||||
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||
if (uxml != null) uxml.CloneTree(root);
|
||||
|
||||
void InitStyles()
|
||||
{
|
||||
if (_headerStyle != null) return;
|
||||
// Cache references
|
||||
statusContainer = root.Q("statusContainer");
|
||||
activePortValue = root.Q<Label>("activePortValue");
|
||||
portButtonsContainer = root.Q("portButtonsContainer");
|
||||
filteringFields = root.Q("filteringFields");
|
||||
|
||||
_headerStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 13,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
padding = new RectOffset(8, 0, 4, 4),
|
||||
};
|
||||
_headerStyle.normal.textColor = AccentColor;
|
||||
// Auto-find button
|
||||
var autoFindBtn = root.Q<Button>("autoFindBtn");
|
||||
var autoFindResult = root.Q<Label>("autoFindResult");
|
||||
if (autoFindBtn != null)
|
||||
autoFindBtn.clicked += () => AutoFindARKitMeshes(autoFindResult);
|
||||
|
||||
_sectionBoxStyle = new GUIStyle("HelpBox")
|
||||
{
|
||||
padding = new RectOffset(10, 10, 8, 8),
|
||||
margin = new RectOffset(0, 0, 4, 8),
|
||||
};
|
||||
// Build dynamic port buttons
|
||||
RebuildPortButtons();
|
||||
|
||||
_portButtonStyle = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
fixedHeight = 36,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
// Track enableFiltering for conditional visibility
|
||||
var enableFilteringProp = serializedObject.FindProperty("enableFiltering");
|
||||
UpdateFilteringVisibility(enableFilteringProp.boolValue);
|
||||
|
||||
_portButtonActiveStyle = new GUIStyle(_portButtonStyle);
|
||||
_portButtonActiveStyle.normal.textColor = Color.white;
|
||||
root.TrackPropertyValue(enableFilteringProp, prop =>
|
||||
{
|
||||
UpdateFilteringVisibility(prop.boolValue);
|
||||
});
|
||||
|
||||
_statusLabelStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 10,
|
||||
};
|
||||
}
|
||||
// Track availablePorts and activePortIndex changes to rebuild port buttons
|
||||
var portsProp = serializedObject.FindProperty("availablePorts");
|
||||
root.TrackPropertyValue(portsProp, _ => RebuildPortButtons());
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
InitStyles();
|
||||
var activeIndexProp = serializedObject.FindProperty("activePortIndex");
|
||||
root.TrackPropertyValue(activeIndexProp, _ => RebuildPortButtons());
|
||||
|
||||
var mocap = (StreamingleFacialReceiver)target;
|
||||
// Play mode status polling
|
||||
root.schedule.Execute(UpdatePlayModeState).Every(200);
|
||||
|
||||
// 타이틀
|
||||
EditorGUILayout.Space(4);
|
||||
DrawTitle();
|
||||
EditorGUILayout.Space(4);
|
||||
return root;
|
||||
}
|
||||
|
||||
// 기본 설정
|
||||
DrawBasicSettings();
|
||||
private void UpdateFilteringVisibility(bool enabled)
|
||||
{
|
||||
if (filteringFields == null) return;
|
||||
filteringFields.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
// 포트 핫스왑
|
||||
DrawPortSection(mocap);
|
||||
private void RebuildPortButtons()
|
||||
{
|
||||
if (portButtonsContainer == null || receiver == null) return;
|
||||
portButtonsContainer.Clear();
|
||||
|
||||
// 필터링
|
||||
DrawFilteringSection();
|
||||
// Update active port label
|
||||
if (activePortValue != null)
|
||||
activePortValue.text = receiver.LOCAL_PORT.ToString();
|
||||
|
||||
// 페이셜 강도
|
||||
DrawIntensitySection();
|
||||
if (receiver.availablePorts == null || receiver.availablePorts.Length == 0) return;
|
||||
|
||||
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()
|
||||
{
|
||||
var rect = EditorGUILayout.GetControlRect(false, 32);
|
||||
EditorGUI.DrawRect(rect, HeaderColor);
|
||||
if (i == receiver.activePortIndex)
|
||||
btn.AddToClassList("facial-port-btn--active");
|
||||
|
||||
var titleStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 15,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
};
|
||||
titleStyle.normal.textColor = Color.white;
|
||||
portButtonsContainer.Add(btn);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 상태 표시
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
var statusRect = new Rect(rect.xMax - 80, rect.y, 75, rect.height);
|
||||
var dotRect = new Rect(statusRect.x - 12, rect.y + rect.height / 2 - 4, 8, 8);
|
||||
EditorGUI.DrawRect(dotRect, ActivePortColor);
|
||||
EditorGUI.LabelField(statusRect, "LIVE", _statusLabelStyle);
|
||||
}
|
||||
}
|
||||
// ARKit 블렌드셰이프 이름 (감지용 최소 세트)
|
||||
private static readonly HashSet<string> ARKitNames = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"EyeBlinkLeft", "EyeBlinkRight", "JawOpen", "MouthClose",
|
||||
"MouthSmileLeft", "MouthSmileRight", "BrowDownLeft", "BrowDownRight",
|
||||
"EyeWideLeft", "EyeWideRight", "MouthFunnel", "MouthPucker",
|
||||
"CheekPuff", "TongueOut", "NoseSneerLeft", "NoseSneerRight",
|
||||
// _L/_R 변형
|
||||
"eyeBlink_L", "eyeBlink_R", "jawOpen", "mouthClose",
|
||||
"mouthSmile_L", "mouthSmile_R", "browDown_L", "browDown_R",
|
||||
};
|
||||
|
||||
void DrawBasicSettings()
|
||||
{
|
||||
EditorGUILayout.BeginVertical(_sectionBoxStyle);
|
||||
private const int MinARKitMatchCount = 5;
|
||||
|
||||
EditorGUILayout.PropertyField(faceMeshRenderers, new GUIContent("Face Mesh Renderers"));
|
||||
EditorGUILayout.Space(2);
|
||||
EditorGUILayout.PropertyField(mirrorMode, new GUIContent("Mirror Mode (L/R Flip)"));
|
||||
private void AutoFindARKitMeshes(Label resultLabel)
|
||||
{
|
||||
if (receiver == null) return;
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
var allSMRs = receiver.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
var found = new List<SkinnedMeshRenderer>();
|
||||
|
||||
void DrawPortSection(StreamingleFacialReceiver mocap)
|
||||
{
|
||||
showConnection = DrawSectionHeader("Port Hot-Swap", showConnection);
|
||||
if (!showConnection) return;
|
||||
foreach (var smr in allSMRs)
|
||||
{
|
||||
if (smr == null || smr.sharedMesh == null) continue;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 포트 표시
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
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();
|
||||
if (matchCount >= MinARKitMatchCount)
|
||||
found.Add(smr);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
if (found.Count == 0)
|
||||
{
|
||||
if (resultLabel != null)
|
||||
resultLabel.text = "ARKit 블렌드셰이프를 가진 메쉬를 찾지 못했습니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 포트 버튼 그리드
|
||||
if (mocap.availablePorts != null && mocap.availablePorts.Length > 0)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
for (int i = 0; i < mocap.availablePorts.Length; i++)
|
||||
{
|
||||
bool isActive = i == mocap.activePortIndex;
|
||||
Undo.RecordObject(target, "Auto Find ARKit Meshes");
|
||||
receiver.faceMeshRenderers = found.ToArray();
|
||||
EditorUtility.SetDirty(target);
|
||||
serializedObject.Update();
|
||||
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = isActive ? ActivePortColor : InactivePortColor;
|
||||
if (resultLabel != null)
|
||||
resultLabel.text = $"{found.Count}개 메쉬 등록 완료";
|
||||
}
|
||||
|
||||
var style = isActive ? _portButtonActiveStyle : _portButtonStyle;
|
||||
string label = mocap.availablePorts[i].ToString();
|
||||
private void UpdatePlayModeState()
|
||||
{
|
||||
if (statusContainer == null) return;
|
||||
|
||||
if (GUILayout.Button(label, style))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
mocap.SwitchToPort(i);
|
||||
}
|
||||
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;
|
||||
}
|
||||
bool isPlaying = Application.isPlaying;
|
||||
if (isPlaying && !statusContainer.ClassListContains("facial-status-container--visible"))
|
||||
statusContainer.AddToClassList("facial-status-container--visible");
|
||||
else if (!isPlaying && statusContainer.ClassListContains("facial-status-container--visible"))
|
||||
statusContainer.RemoveFromClassList("facial-status-container--visible");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f26ead3daa5f7de479c0c8547b3a792d
|
||||
TextScriptImporter:
|
||||
guid: 22c3df2257c238b459de9d41c03bff11
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
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
|
||||
guid: 500a6ad5f33a60347b30b0ca73a3e650
|
||||
guid: 90070596c9064a94885e25321c3db607
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
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
|
||||
guid: 227ed0897c5a66b47b6431f0c95fa98a
|
||||
guid: 3cfda294f25f5fa4a8c3d08d4bd8590c
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
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
|
||||
guid: b9d33120f9b2266498b51080310c89e6
|
||||
guid: c9497b0da91a15c49bb472beff83a9b9
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
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))]
|
||||
public class RetargetingRemoteControllerEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty httpPortProp;
|
||||
private SerializedProperty wsPortProp;
|
||||
private SerializedProperty autoStartProp;
|
||||
private SerializedProperty registeredCharactersProp;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
httpPortProp = serializedObject.FindProperty("httpPort");
|
||||
wsPortProp = serializedObject.FindProperty("wsPort");
|
||||
autoStartProp = serializedObject.FindProperty("autoStart");
|
||||
registeredCharactersProp = serializedObject.FindProperty("registeredCharacters");
|
||||
@ -28,7 +26,7 @@ namespace KindRetargeting.Editor
|
||||
|
||||
// 헤더
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("리타게팅 웹 리모컨", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField("리타게팅 리모컨 (WebSocket)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// 상태 표시
|
||||
@ -67,44 +65,13 @@ namespace KindRetargeting.Editor
|
||||
EditorGUILayout.LabelField("서버 설정", EditorStyles.boldLabel);
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
|
||||
EditorGUILayout.PropertyField(httpPortProp, new GUIContent("HTTP 포트"));
|
||||
EditorGUILayout.PropertyField(wsPortProp, new GUIContent("WebSocket 포트"));
|
||||
EditorGUILayout.PropertyField(autoStartProp, new GUIContent("자동 시작"));
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 접속 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(5);
|
||||
EditorGUILayout.HelpBox("리타게팅 UI는 Streamingle Dashboard에 통합되었습니다.\n대시보드의 Retargeting 탭에서 사용하세요.", MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
@ -163,22 +130,5 @@ namespace KindRetargeting.Editor
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
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>
|
||||
/// 리타게팅 원격 제어 컨트롤러
|
||||
/// HTTP 서버와 WebSocket 서버를 관리하고 메시지를 처리합니다.
|
||||
/// WebSocket 서버를 관리하고 메시지를 처리합니다.
|
||||
/// (HTTP UI는 Streamingle Dashboard에 통합됨)
|
||||
/// </summary>
|
||||
public class RetargetingRemoteController : MonoBehaviour
|
||||
{
|
||||
[Header("서버 설정")]
|
||||
[SerializeField] private int httpPort = 8080;
|
||||
[SerializeField] private int wsPort = 8081;
|
||||
[SerializeField] private int wsPort = 64212;
|
||||
[SerializeField] private bool autoStart = true;
|
||||
|
||||
[Header("캐릭터 등록")]
|
||||
[SerializeField] private List<CustomRetargetingScript> registeredCharacters = new List<CustomRetargetingScript>();
|
||||
|
||||
private RetargetingHTTPServer httpServer;
|
||||
private RetargetingWebSocketServer wsServer;
|
||||
|
||||
private Queue<Action> mainThreadActions = new Queue<Action>();
|
||||
|
||||
public bool IsRunning => httpServer?.IsRunning == true && wsServer?.IsRunning == true;
|
||||
public int HttpPort { get => httpPort; set => httpPort = value; }
|
||||
public bool IsRunning => wsServer?.IsRunning == true;
|
||||
public int WsPort { get => wsPort; set => wsPort = value; }
|
||||
public bool AutoStart { get => autoStart; set => autoStart = value; }
|
||||
|
||||
@ -74,21 +72,16 @@ namespace KindRetargeting.Remote
|
||||
{
|
||||
if (IsRunning) return;
|
||||
|
||||
httpServer = new RetargetingHTTPServer(httpPort, wsPort);
|
||||
wsServer = new RetargetingWebSocketServer(wsPort);
|
||||
|
||||
wsServer.OnMessageReceived += OnWebSocketMessage;
|
||||
|
||||
httpServer.Start();
|
||||
wsServer.Start();
|
||||
|
||||
Debug.Log($"[RetargetingRemote] 서버 시작됨 - HTTP: {httpPort}, WS: {wsPort}");
|
||||
Debug.Log($"[RetargetingRemote] WebSocket 서버 시작됨 - WS: {wsPort}");
|
||||
}
|
||||
|
||||
public void StopServer()
|
||||
{
|
||||
wsServer?.Stop();
|
||||
httpServer?.Stop();
|
||||
|
||||
if (wsServer != null)
|
||||
{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1391a0125a12fdb46bb61bc345044032
|
||||
guid: 49e714e9f3f2ff84fabab3905897e4f3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
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 UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using Streamingle.Debug;
|
||||
using Streamingle.Debugging;
|
||||
using System.Linq;
|
||||
|
||||
namespace Streamingle
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using UnityRawInput;
|
||||
using UnityEngine.Rendering;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@ -82,26 +81,10 @@ public class CameraControlSystem : MonoBehaviour
|
||||
|
||||
// DOF 타겟 찾기
|
||||
FindDOFTargets();
|
||||
|
||||
InitializeRawInput();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// RawInput 정리
|
||||
if (RawInput.IsRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
||||
// 다른 컴포넌트가 사용 중일 수 있으므로 Stop은 호출하지 않음
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// RawInput 정리 실패 무시
|
||||
}
|
||||
}
|
||||
|
||||
// CameraManager 이벤트 해제
|
||||
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()
|
||||
{
|
||||
HandleFOVControl();
|
||||
@ -146,36 +108,8 @@ public class CameraControlSystem : MonoBehaviour
|
||||
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()
|
||||
{
|
||||
// Unity Input으로도 처리 (RawInput과 병행하여 안정성 확보)
|
||||
if (Input.GetKeyDown(KeyCode.F13))
|
||||
{
|
||||
if (isFOVMode) IncreaseFOV();
|
||||
|
||||
@ -3,7 +3,6 @@ using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityRawInput;
|
||||
using System.Linq;
|
||||
using Unity.Cinemachine;
|
||||
using Streamingle;
|
||||
@ -12,127 +11,11 @@ using KindRetargeting;
|
||||
public class CameraManager : MonoBehaviour, IController
|
||||
{
|
||||
#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]
|
||||
public class CameraPreset : ISerializationCallbackReceiver
|
||||
{
|
||||
public string presetName = "New Camera Preset";
|
||||
public CinemachineCamera virtualCamera;
|
||||
public HotkeyCommand hotkey;
|
||||
[System.NonSerialized] public bool isEditingHotkey = false;
|
||||
|
||||
// 마우스 조작 허용 여부
|
||||
[Tooltip("이 카메라에서 마우스 조작(회전, 팬, 줌 등)을 허용할지 여부")]
|
||||
@ -174,11 +57,10 @@ public class CameraManager : MonoBehaviour, IController
|
||||
{
|
||||
virtualCamera = camera;
|
||||
presetName = camera?.gameObject.name ?? "Unnamed Camera";
|
||||
hotkey = new HotkeyCommand();
|
||||
allowMouseControl = true;
|
||||
}
|
||||
|
||||
public bool IsValid() => virtualCamera != null && hotkey != null;
|
||||
public bool IsValid() => virtualCamera != null;
|
||||
|
||||
// 초기 상태 저장 (Alt+Q 복원용)
|
||||
public void SaveInitialState()
|
||||
@ -336,7 +218,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
private void Awake()
|
||||
{
|
||||
InitializeInputHandler();
|
||||
InitializeRawInput();
|
||||
InitializeCameraPresets();
|
||||
|
||||
// StreamDeckServerManager 찾기
|
||||
@ -358,12 +239,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (RawInput.IsRunning)
|
||||
{
|
||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
||||
RawInput.Stop();
|
||||
}
|
||||
|
||||
// 블렌드 리소스 정리
|
||||
if (prevCameraRenderTexture != null)
|
||||
{
|
||||
@ -386,20 +261,7 @@ public class CameraManager : MonoBehaviour, IController
|
||||
|
||||
if (!IsValidSetup) return;
|
||||
|
||||
UpdateHotkeyRecording();
|
||||
HandleCameraControls();
|
||||
HandleHotkeys();
|
||||
}
|
||||
|
||||
private void UpdateHotkeyRecording()
|
||||
{
|
||||
foreach (var preset in cameraPresets)
|
||||
{
|
||||
if (preset?.hotkey?.isRecording == true)
|
||||
{
|
||||
preset.hotkey.UpdateRecording();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (cameraPresets == null)
|
||||
@ -558,11 +408,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var preset in cameraPresets.Where(p => p?.hotkey != null))
|
||||
{
|
||||
preset.hotkey.InitializeUnityKeys();
|
||||
}
|
||||
|
||||
// 모든 프리셋의 초기 상태 저장
|
||||
foreach (var preset in cameraPresets.Where(p => p?.IsValid() == true))
|
||||
{
|
||||
@ -631,39 +476,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
|
||||
#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
|
||||
|
||||
/// <summary>
|
||||
@ -1350,37 +1162,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
}
|
||||
#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
|
||||
// 카메라 목록 데이터 반환 (HTTP 요청 시 직접 호출됨)
|
||||
public CameraListData GetCameraListData()
|
||||
@ -1389,8 +1170,7 @@ public class CameraManager : MonoBehaviour, IController
|
||||
{
|
||||
index = index,
|
||||
name = preset?.virtualCamera?.gameObject.name ?? $"Camera {index}",
|
||||
isActive = currentPreset == preset,
|
||||
hotkey = preset?.hotkey?.ToString() ?? "설정되지 않음"
|
||||
isActive = currentPreset == preset
|
||||
}).ToArray();
|
||||
|
||||
return new CameraListData
|
||||
@ -1423,7 +1203,6 @@ public class CameraManager : MonoBehaviour, IController
|
||||
public int index;
|
||||
public string name;
|
||||
public bool isActive;
|
||||
public string hotkey;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
|
||||
@ -2,7 +2,6 @@ using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Streamingle;
|
||||
using UnityRawInput;
|
||||
using UnityEngine.Events;
|
||||
|
||||
public class EventController : MonoBehaviour, IController
|
||||
@ -14,95 +13,13 @@ public class EventController : MonoBehaviour, IController
|
||||
[Header("Event Settings")]
|
||||
public string groupName = "New Event Group";
|
||||
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)
|
||||
{
|
||||
groupName = name;
|
||||
hotkeys = new List<RawKey>();
|
||||
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()
|
||||
{
|
||||
if (unityEvent != null)
|
||||
@ -111,35 +28,7 @@ public class EventController : MonoBehaviour, IController
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
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);
|
||||
}
|
||||
public override string ToString() => groupName;
|
||||
}
|
||||
#endregion
|
||||
|
||||
@ -150,10 +39,10 @@ public class EventController : MonoBehaviour, IController
|
||||
|
||||
#region Fields
|
||||
[SerializeField] public List<EventGroup> eventGroups = new List<EventGroup>();
|
||||
|
||||
|
||||
[Header("Event Control Settings")]
|
||||
[SerializeField] private bool autoFindEvents = false; // 태그 기능 비활성화
|
||||
|
||||
|
||||
private EventGroup currentGroup;
|
||||
private StreamDeckServerManager streamDeckManager;
|
||||
#endregion
|
||||
@ -167,8 +56,7 @@ public class EventController : MonoBehaviour, IController
|
||||
private void Awake()
|
||||
{
|
||||
InitializeEventGroups();
|
||||
InitializeRawInput();
|
||||
|
||||
|
||||
// StreamDeckServerManager 찾기
|
||||
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
||||
if (streamDeckManager == null)
|
||||
@ -176,21 +64,6 @@ public class EventController : MonoBehaviour, IController
|
||||
Debug.LogWarning("[EventController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (RawInput.IsRunning)
|
||||
{
|
||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
||||
RawInput.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
UpdateHotkeyRecording();
|
||||
HandleHotkeys();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
@ -206,46 +79,6 @@ public class EventController : MonoBehaviour, IController
|
||||
|
||||
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
|
||||
|
||||
#region Public Methods
|
||||
@ -288,7 +121,7 @@ public class EventController : MonoBehaviour, IController
|
||||
|
||||
Debug.Log($"[EventController] 이벤트 실행: {group.groupName}");
|
||||
group.ExecuteEvent();
|
||||
|
||||
|
||||
// 이벤트 발생 알림
|
||||
OnEventExecuted?.Invoke(group);
|
||||
|
||||
@ -314,7 +147,7 @@ public class EventController : MonoBehaviour, IController
|
||||
public void AddGroup(string groupName, GameObject targetObject = null)
|
||||
{
|
||||
var newGroup = new EventGroup(groupName);
|
||||
|
||||
|
||||
if (targetObject != null)
|
||||
{
|
||||
// 대상 오브젝트에 UnityEvent 컴포넌트가 있다면 연결
|
||||
@ -324,7 +157,7 @@ public class EventController : MonoBehaviour, IController
|
||||
newGroup.unityEvent = eventComponent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
eventGroups.Add(newGroup);
|
||||
Debug.Log($"[EventController] 이벤트 그룹 추가: {groupName}");
|
||||
}
|
||||
@ -339,39 +172,15 @@ public class EventController : MonoBehaviour, IController
|
||||
|
||||
var groupToRemove = eventGroups[index];
|
||||
eventGroups.RemoveAt(index);
|
||||
|
||||
|
||||
if (currentGroup == groupToRemove)
|
||||
{
|
||||
currentGroup = eventGroups.Count > 0 ? eventGroups[0] : null;
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
{
|
||||
// StreamDeck에 이벤트 변경 알림
|
||||
@ -386,15 +195,14 @@ public class EventController : MonoBehaviour, IController
|
||||
public EventListData GetEventListData()
|
||||
{
|
||||
var eventData = new EventPresetData[eventGroups.Count];
|
||||
|
||||
|
||||
for (int i = 0; i < eventGroups.Count; i++)
|
||||
{
|
||||
var group = eventGroups[i];
|
||||
eventData[i] = new EventPresetData
|
||||
{
|
||||
index = i,
|
||||
name = group.groupName,
|
||||
hotkey = group.hotkeys.Any() ? string.Join(" + ", group.hotkeys) : "설정되지 않음"
|
||||
name = group.groupName
|
||||
};
|
||||
}
|
||||
|
||||
@ -433,7 +241,6 @@ public class EventController : MonoBehaviour, IController
|
||||
{
|
||||
public int index;
|
||||
public string name;
|
||||
public string hotkey;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
@ -491,4 +298,4 @@ public class EventController : MonoBehaviour, IController
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Streamingle;
|
||||
using UnityRawInput;
|
||||
|
||||
public class ItemController : MonoBehaviour, IController
|
||||
{
|
||||
@ -13,98 +12,16 @@ public class ItemController : MonoBehaviour, IController
|
||||
[Header("Group Settings")]
|
||||
public string groupName = "New Item Group";
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (itemObjects == null) return;
|
||||
|
||||
|
||||
foreach (var obj in itemObjects)
|
||||
{
|
||||
if (obj != null)
|
||||
@ -117,40 +34,12 @@ public class ItemController : MonoBehaviour, IController
|
||||
public bool IsActive()
|
||||
{
|
||||
if (itemObjects == null || itemObjects.Length == 0) return false;
|
||||
|
||||
|
||||
// 모든 오브젝트가 활성화되어 있으면 true
|
||||
return itemObjects.All(obj => obj != null && obj.activeSelf);
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
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);
|
||||
}
|
||||
public override string ToString() => groupName;
|
||||
}
|
||||
#endregion
|
||||
|
||||
@ -161,11 +50,11 @@ public class ItemController : MonoBehaviour, IController
|
||||
|
||||
#region Fields
|
||||
[SerializeField] public List<ItemGroup> itemGroups = new List<ItemGroup>();
|
||||
|
||||
|
||||
[Header("Item Control Settings")]
|
||||
[SerializeField] private bool autoFindGroups = true;
|
||||
[SerializeField] private string groupTag = "ItemGroup";
|
||||
|
||||
|
||||
private ItemGroup currentGroup;
|
||||
private StreamDeckServerManager streamDeckManager;
|
||||
#endregion
|
||||
@ -179,8 +68,7 @@ public class ItemController : MonoBehaviour, IController
|
||||
private void Awake()
|
||||
{
|
||||
InitializeItemGroups();
|
||||
InitializeRawInput();
|
||||
|
||||
|
||||
// StreamDeckServerManager 찾기
|
||||
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
|
||||
if (streamDeckManager == null)
|
||||
@ -188,21 +76,6 @@ public class ItemController : MonoBehaviour, IController
|
||||
Debug.LogWarning("[ItemController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (RawInput.IsRunning)
|
||||
{
|
||||
RawInput.OnKeyDown -= HandleRawKeyDown;
|
||||
RawInput.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
UpdateHotkeyRecording();
|
||||
HandleHotkeys();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
@ -228,52 +101,9 @@ public class ItemController : MonoBehaviour, IController
|
||||
|
||||
// 유효하지 않은 그룹 제거
|
||||
itemGroups.RemoveAll(group => group == null || group.itemObjects == null || group.itemObjects.Length == 0);
|
||||
|
||||
|
||||
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
|
||||
|
||||
#region Public Methods
|
||||
@ -325,7 +155,7 @@ public class ItemController : MonoBehaviour, IController
|
||||
public void ToggleGroup(ItemGroup group)
|
||||
{
|
||||
if (group == null) return;
|
||||
|
||||
|
||||
int index = itemGroups.IndexOf(group);
|
||||
if (index >= 0)
|
||||
{
|
||||
@ -368,7 +198,7 @@ public class ItemController : MonoBehaviour, IController
|
||||
{
|
||||
newGroup.itemObjects = objects;
|
||||
}
|
||||
|
||||
|
||||
itemGroups.Add(newGroup);
|
||||
NotifyItemChanged();
|
||||
Debug.Log($"[ItemController] 그룹 추가: {groupName}");
|
||||
@ -390,24 +220,6 @@ public class ItemController : MonoBehaviour, IController
|
||||
NotifyItemChanged();
|
||||
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
|
||||
|
||||
#region StreamDeck Integration
|
||||
@ -442,8 +254,7 @@ public class ItemController : MonoBehaviour, IController
|
||||
{
|
||||
index = i,
|
||||
name = g.groupName,
|
||||
isActive = g.IsActive(),
|
||||
hotkey = g.ToString()
|
||||
isActive = g.IsActive()
|
||||
}).ToArray(),
|
||||
current_index = CurrentIndex
|
||||
};
|
||||
@ -480,7 +291,6 @@ public class ItemController : MonoBehaviour, IController
|
||||
public int index;
|
||||
public string name;
|
||||
public bool isActive;
|
||||
public string hotkey;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
@ -543,22 +353,10 @@ public class ItemController : MonoBehaviour, IController
|
||||
case "deactivate_all":
|
||||
DeactivateAllGroups();
|
||||
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:
|
||||
Debug.LogWarning($"[ItemController] 알 수 없는 액션: {actionId}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 UnityEditor;
|
||||
using UnityRawInput;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditor.UIElements;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
[CustomEditor(typeof(CameraManager))]
|
||||
public class CameraManagerEditor : Editor
|
||||
{
|
||||
private HashSet<KeyCode> currentKeys = new HashSet<KeyCode>();
|
||||
private bool isApplicationPlaying;
|
||||
private bool isListening = false;
|
||||
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml";
|
||||
private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss";
|
||||
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||
|
||||
// Foldout 상태
|
||||
private bool showCameraPresets = true;
|
||||
private bool showControlSettings = true;
|
||||
private bool showSmoothingSettings = true;
|
||||
private bool showZoomSettings = true;
|
||||
private bool showFOVSettings = true;
|
||||
private bool showRotationTarget = true;
|
||||
private bool showBlendSettings = true;
|
||||
private VisualElement presetsContainer;
|
||||
private Label presetsTitleLabel;
|
||||
private CameraManager manager;
|
||||
|
||||
// SerializedProperties
|
||||
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()
|
||||
public override VisualElement CreateInspectorGUI()
|
||||
{
|
||||
isApplicationPlaying = Application.isPlaying;
|
||||
manager = (CameraManager)target;
|
||||
var root = new VisualElement();
|
||||
|
||||
// SerializedProperties 가져오기
|
||||
rotationSensitivityProp = serializedObject.FindProperty("rotationSensitivity");
|
||||
panSpeedProp = serializedObject.FindProperty("panSpeed");
|
||||
zoomSpeedProp = serializedObject.FindProperty("zoomSpeed");
|
||||
orbitSpeedProp = serializedObject.FindProperty("orbitSpeed");
|
||||
movementSmoothingProp = serializedObject.FindProperty("movementSmoothing");
|
||||
rotationSmoothingProp = serializedObject.FindProperty("rotationSmoothing");
|
||||
minZoomDistanceProp = serializedObject.FindProperty("minZoomDistance");
|
||||
maxZoomDistanceProp = serializedObject.FindProperty("maxZoomDistance");
|
||||
fovSensitivityProp = serializedObject.FindProperty("fovSensitivity");
|
||||
minFOVProp = serializedObject.FindProperty("minFOV");
|
||||
maxFOVProp = serializedObject.FindProperty("maxFOV");
|
||||
useAvatarHeadAsTargetProp = serializedObject.FindProperty("useAvatarHeadAsTarget");
|
||||
manualRotationTargetProp = serializedObject.FindProperty("manualRotationTarget");
|
||||
useBlendTransitionProp = serializedObject.FindProperty("useBlendTransition");
|
||||
blendTimeProp = serializedObject.FindProperty("blendTime");
|
||||
useRealtimeBlendProp = serializedObject.FindProperty("useRealtimeBlend");
|
||||
// Stylesheets
|
||||
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);
|
||||
|
||||
// UXML template
|
||||
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
||||
if (uxml != null) uxml.CloneTree(root);
|
||||
|
||||
// Conditional UI logic
|
||||
SetupZoomValidation(root);
|
||||
SetupFOVValidation(root);
|
||||
SetupRotationTargetLogic(root);
|
||||
SetupBlendTransitionLogic(root);
|
||||
|
||||
// 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,
|
||||
margin = new RectOffset(0, 0, 5, 5)
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
modeInfo.style.display = DisplayStyle.Flex;
|
||||
modeInfo.text = useRealtime
|
||||
? "실시간 모드: 블렌딩 중 두 카메라 모두 실시간으로 렌더링됩니다.\n성능 비용이 증가하지만 이전 카메라도 움직입니다."
|
||||
: "스냅샷 모드: 이전 화면을 캡처한 후 블렌딩합니다.\n성능 효율적이지만 이전 화면이 정지합니다.";
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("알림", "Scene에 CinemachineCamera가 없습니다.", "확인");
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
modeInfo.style.display = DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
// 활성 프리셋 표시를 위한 배경색
|
||||
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)
|
||||
{
|
||||
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);
|
||||
GUI.backgroundColor = Color.white;
|
||||
// Up button
|
||||
var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2" };
|
||||
upBtn.AddToClassList("preset-reorder-btn");
|
||||
upBtn.SetEnabled(index > 0);
|
||||
headerRow.Add(upBtn);
|
||||
|
||||
// 프리셋 헤더
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
// Down button
|
||||
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);
|
||||
|
||||
// 인덱스 번호
|
||||
GUILayout.Label($"{index + 1}", EditorStyles.boldLabel, GUILayout.Width(20));
|
||||
// Delete button
|
||||
var deleteBtn = new Button(() => DeletePreset(index)) { text = "X" };
|
||||
deleteBtn.AddToClassList("preset-delete-btn");
|
||||
headerRow.Add(deleteBtn);
|
||||
|
||||
// 프리셋 이름 필드
|
||||
EditorGUI.BeginChangeCheck();
|
||||
preset.presetName = EditorGUILayout.TextField(preset.presetName, GUILayout.MinWidth(100));
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
item.Add(headerRow);
|
||||
|
||||
// --- Property fields ---
|
||||
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);
|
||||
}
|
||||
});
|
||||
fields.Add(cameraField);
|
||||
|
||||
// 활성 표시
|
||||
if (isActive)
|
||||
var mouseToggle = new Toggle("Allow Mouse Control")
|
||||
{
|
||||
GUILayout.Label("[Active]", EditorStyles.miniLabel, GUILayout.Width(50));
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
// 위/아래 버튼
|
||||
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())
|
||||
tooltip = "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부",
|
||||
value = preset.allowMouseControl
|
||||
};
|
||||
int mi = index;
|
||||
mouseToggle.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
Undo.RecordObject(target, "Toggle Mouse Control");
|
||||
manager.cameraPresets[mi].allowMouseControl = evt.newValue;
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
});
|
||||
fields.Add(mouseToggle);
|
||||
|
||||
// 마우스 조작 허용 설정
|
||||
EditorGUI.BeginChangeCheck();
|
||||
preset.allowMouseControl = EditorGUILayout.Toggle(
|
||||
new GUIContent("Allow Mouse Control", "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부"),
|
||||
preset.allowMouseControl);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
item.Add(fields);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void AddPreset()
|
||||
{
|
||||
var newCamera = FindFirstObjectByType<CinemachineCamera>();
|
||||
if (newCamera != null)
|
||||
{
|
||||
Undo.RecordObject(target, "Add Camera Preset");
|
||||
manager.cameraPresets.Add(new CameraManager.CameraPreset(newCamera));
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
|
||||
// 핫키 설정 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;
|
||||
RebuildPresetList();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 핫키 표시
|
||||
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);
|
||||
}
|
||||
EditorUtility.DisplayDialog("알림", "Scene에 CinemachineCamera가 없습니다.", "확인");
|
||||
}
|
||||
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];
|
||||
manager.cameraPresets[indexA] = manager.cameraPresets[indexB];
|
||||
manager.cameraPresets[indexB] = temp;
|
||||
var preset = manager.cameraPresets[index];
|
||||
if (EditorUtility.DisplayDialog("프리셋 삭제",
|
||||
$"프리셋 '{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);
|
||||
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;
|
||||
if (e != null)
|
||||
{
|
||||
if (e.type == EventType.KeyDown && e.keyCode != KeyCode.None)
|
||||
{
|
||||
// 마우스 버튼 제외
|
||||
if (e.keyCode != KeyCode.Mouse0 && e.keyCode != KeyCode.Mouse1 && e.keyCode != KeyCode.Mouse2)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
var item = presetsContainer[i];
|
||||
bool isActive = manager.CurrentPreset == manager.cameraPresets[i];
|
||||
|
||||
if (isActive && !item.ClassListContains("preset-item--active"))
|
||||
item.AddToClassList("preset-item--active");
|
||||
else if (!isActive && item.ClassListContains("preset-item--active"))
|
||||
item.RemoveFromClassList("preset-item--active");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddKey(KeyCode keyCode)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -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