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:
user 2026-02-16 02:51:43 +09:00
parent 2f6ddc3ccb
commit 41270a34f5
109 changed files with 6378 additions and 4943 deletions

View File

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

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 53a33bd699913a642832d9cefe527f21
guid: a2ec7617f65956541ad157e05b2c005b
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

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

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: e5982fe4cc6a11640a72e87132c66e5e
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -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
}

View File

@ -1,12 +0,0 @@
fileFormatVersion: 2
guid: 6226842a13d56fc4b9f6ffc41f7ef0ec
timeCreated: 1467839953
licenseType: Free
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

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

View File

@ -1,6 +1,7 @@
fileFormatVersion: 2
guid: f26ead3daa5f7de479c0c8547b3a792d
TextScriptImporter:
guid: 22c3df2257c238b459de9d41c03bff11
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:

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

View 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

View 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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 891f2c72647a45a42879bd64a9da5638
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: b9d33120f9b2266498b51080310c89e6
guid: c9497b0da91a15c49bb472beff83a9b9
TextScriptImporter:
externalObjects: {}
userData:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d1e18befc03cb442a30ade3a2a05bfe
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@ -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

View 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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 4fc5c7da37e69a14f901a96cc1ad28ea
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

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

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: b7e5281735e39194f8573efa503bc0f1
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -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 "";
}
}
}

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 882d24d531c184345901e519b629af77

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e5cbe9ba9e3b93545b67273bcb17de08
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 1a1732001c5b56a4c902abb5d2613871
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -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');";
}
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: aefa700f1b9823544bf33043b01244b2

View File

@ -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)
{

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1391a0125a12fdb46bb61bc345044032
guid: 49e714e9f3f2ff84fabab3905897e4f3
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@ -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 "";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a876625bf5e790e4bab87197b239bd61

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d8b7827594b941743a8b687224c91d25
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -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

View File

@ -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>

View File

@ -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

View 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');";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64ec23e020e1e6d408eec3d0a0460741

View File

@ -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

View File

@ -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();

View File

@ -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]

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e34e02a9a1a02b84290f3e1f4a015d5c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 093470731ec59174a877abcd20eed026

View File

@ -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
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3fabd42850d64c40914ec3c93a70dbc

View File

@ -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("재접속에 성공한 클라이언트가 없습니다!");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ee0e22a9c0c4b6429a0d7455f411d10

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f44ada355c002e40a9ed608c59b4d2d

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d28f8f25a08e074c9d9bdcd24459f45

View File

@ -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 "";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7993b7fbc5617d7438308ee2ccf980f6

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 675f8ead0a5faa546b159682fc9c9128

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b601928d76ee394397d97284c7c2aa5

View File

@ -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
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 559abc9e879f4f740af25a65b7696919

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7eb33d4f3e03bc2468850c9caede24c1

View File

@ -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
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aec9980e487d295439589fb890e0ac3f

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 730652e4327a759459b4f7d26a0acb8f

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4ece65516aefb684caf7a11f9ee5357b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: d0f81967be3194245b71b598816fb72d
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

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

View File

@ -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

View File

@ -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를 조절합니다.&#10;위로 밀면 줌인(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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 601152077f9f1bd40ac515a1e36aae35
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 589b18a6eea2c5e4191e6aa4e624b541
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -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