diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs index 06f132eb2..29df9a779 100644 --- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs +++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs @@ -439,6 +439,9 @@ public class StreamDeckServerManager : MonoBehaviour case "get_drone_state": HandleGetDroneState(service); break; + case "toggle_default_blend": + HandleToggleDefaultBlend(); + break; // 아이템 case "toggle_item": @@ -642,6 +645,13 @@ public class StreamDeckServerManager : MonoBehaviour HandleGetDroneState(service); } + private void HandleToggleDefaultBlend() + { + if (cameraManager == null) return; + cameraManager.ToggleDefaultBlend(); + // ToggleDefaultBlend 내부에서 NotifyCameraChanged를 호출하여 모든 클라이언트에 브로드캐스트됩니다. + } + private void HandleGetDroneState(StreamDeckService service) { if (cameraManager == null) return; diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs index 5b455ed71..2205fab30 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs @@ -365,6 +365,61 @@ public class CameraManager : MonoBehaviour, IController streamDeckManager.NotifyCameraChanged(); } } + + /// + /// Main Camera에 달린 CinemachineBrain의 Default Blend 스타일을 Cut ↔ EaseInOut 로 토글합니다. + /// + public void ToggleDefaultBlend() + { + var brain = GetCinemachineBrain(); + if (brain == null) + { + Debug.LogWarning("[CameraManager] CinemachineBrain을 찾을 수 없습니다. Main Camera에 추가되어 있어야 합니다."); + return; + } + + var blend = brain.DefaultBlend; + blend.Style = (blend.Style == CinemachineBlendDefinition.Styles.Cut) + ? CinemachineBlendDefinition.Styles.EaseInOut + : CinemachineBlendDefinition.Styles.Cut; + brain.DefaultBlend = blend; + + NotifyStreamDeckCameraStateChanged(); + } + + public bool IsDefaultBlendCut + { + get + { + var brain = GetCinemachineBrain(); + return brain != null && brain.DefaultBlend.Style == CinemachineBlendDefinition.Styles.Cut; + } + } + + public string DefaultBlendStyleName + { + get + { + var brain = GetCinemachineBrain(); + return brain != null ? brain.DefaultBlend.Style.ToString() : "Unknown"; + } + } + + private CinemachineBrain cachedBrain; + + public CinemachineBrain GetCinemachineBrain() + { + if (cachedBrain != null) return cachedBrain; + + var mainCam = Camera.main; + if (mainCam != null) + { + cachedBrain = mainCam.GetComponent(); + if (cachedBrain != null) return cachedBrain; + } + cachedBrain = FindFirstObjectByType(); + return cachedBrain; + } #endregion #region Initialization @@ -1175,7 +1230,9 @@ public class CameraManager : MonoBehaviour, IController camera_name = currentPreset.virtualCamera?.gameObject.name ?? "Unknown", preset_name = currentPreset.presetName, total_cameras = cameraPresets.Count, - is_drone_mode = IsDroneModeActive + is_drone_mode = IsDroneModeActive, + default_blend_style = DefaultBlendStyleName, + is_default_blend_cut = IsDefaultBlendCut }; } @@ -1203,6 +1260,8 @@ public class CameraManager : MonoBehaviour, IController public string preset_name; public int total_cameras; public bool is_drone_mode; + public string default_blend_style; + public bool is_default_blend_cut; } #endregion @@ -1253,6 +1312,10 @@ public class CameraManager : MonoBehaviour, IController // 드론 상태는 GetCurrentCameraState()의 is_drone_mode로 반환됨 break; + case "toggle_default_blend": + ToggleDefaultBlend(); + break; + default: Debug.LogWarning($"[CameraManager] 알 수 없는 액션: {actionId}"); break; diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs index 46d4fc91b..eebb9af60 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs @@ -213,6 +213,23 @@ public class RuntimeControlPanelManager return; } + // Default Blend 토글 (Cinemachine Brain) + var blendRow = new VisualElement(); + blendRow.AddToClassList("action-row"); + + bool isCut = cam.IsDefaultBlendCut; + string styleName = cam.DefaultBlendStyleName; + var blendBtn = MakeButton( + $"Blend: {styleName} ▶ {(isCut ? "EaseInOut" : "Cut")}로 전환", + isCut ? "action-btn--secondary" : "action-btn--success"); + blendBtn.clicked += () => + { + cam.ToggleDefaultBlend(); + SwitchCategory("camera"); + }; + blendRow.Add(blendBtn); + actionList.Add(blendRow); + var data = cam.GetCameraListData(); if (data?.presets == null) return; diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs b/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs index d0efa35c4..abbce54d2 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; using UnityEditor; using UnityEngine.UIElements; @@ -11,8 +12,13 @@ public class CameraManagerEditor : Editor private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss"; private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; + private VisualElement presetsSection; private VisualElement presetsContainer; + private VisualElement dropZone; private Label presetsTitleLabel; + private Button defaultBlendButton; + private Label defaultBlendStatusLabel; + private HelpBox brainNotFoundWarning; private CameraManager manager; public override VisualElement CreateInspectorGUI() @@ -36,6 +42,9 @@ public class CameraManagerEditor : Editor SetupRotationTargetLogic(root); SetupBlendTransitionLogic(root); + // Cinemachine Brain default blend toggle + SetupDefaultBlendSection(root); + // Preset list (dynamic) BuildPresetsSection(root); @@ -162,12 +171,104 @@ public class CameraManagerEditor : Editor #endregion + #region Cinemachine Brain + + private void SetupDefaultBlendSection(VisualElement root) + { + var group = root.Q("defaultBlendGroup"); + brainNotFoundWarning = root.Q("brainNotFoundWarning"); + if (group == null) return; + + var row = new VisualElement(); + row.AddToClassList("blend-toggle-row"); + + defaultBlendStatusLabel = new Label("현재 Default Blend:"); + defaultBlendStatusLabel.AddToClassList("blend-toggle-label"); + row.Add(defaultBlendStatusLabel); + + defaultBlendButton = new Button(ToggleEditorDefaultBlend) { text = "-" }; + defaultBlendButton.AddToClassList("blend-toggle-btn"); + row.Add(defaultBlendButton); + + group.Add(row); + + // 인스펙터가 포커스를 가질 때 주기적으로 UI 동기화 (씬에서 직접 편집한 경우 반영) + root.schedule.Execute(UpdateDefaultBlendUI).Every(500); + UpdateDefaultBlendUI(); + } + + private CinemachineBrain FindBrain() + { + var mainCam = Camera.main; + if (mainCam != null) + { + var brain = mainCam.GetComponent(); + if (brain != null) return brain; + } + return FindFirstObjectByType(); + } + + private void UpdateDefaultBlendUI() + { + if (defaultBlendButton == null) return; + + var brain = FindBrain(); + if (brain == null) + { + defaultBlendButton.text = "Brain 없음"; + defaultBlendButton.SetEnabled(false); + defaultBlendButton.RemoveFromClassList("blend-toggle-btn--cut"); + defaultBlendButton.RemoveFromClassList("blend-toggle-btn--ease"); + + if (defaultBlendStatusLabel != null) + defaultBlendStatusLabel.text = "CinemachineBrain을 찾을 수 없음"; + + if (brainNotFoundWarning != null) + brainNotFoundWarning.style.display = DisplayStyle.Flex; + return; + } + + if (brainNotFoundWarning != null) + brainNotFoundWarning.style.display = DisplayStyle.None; + + var style = brain.DefaultBlend.Style; + bool isCut = style == CinemachineBlendDefinition.Styles.Cut; + bool isEase = style == CinemachineBlendDefinition.Styles.EaseInOut; + + if (defaultBlendStatusLabel != null) + defaultBlendStatusLabel.text = $"현재 Default Blend: {style}"; + + defaultBlendButton.SetEnabled(true); + defaultBlendButton.text = isCut ? "▶ EaseInOut 으로 전환" : "▶ Cut 으로 전환"; + + defaultBlendButton.EnableInClassList("blend-toggle-btn--cut", isCut); + defaultBlendButton.EnableInClassList("blend-toggle-btn--ease", isEase); + } + + private void ToggleEditorDefaultBlend() + { + var brain = FindBrain(); + if (brain == null) return; + + Undo.RecordObject(brain, "Toggle CinemachineBrain Default Blend"); + var blend = brain.DefaultBlend; + blend.Style = (blend.Style == CinemachineBlendDefinition.Styles.Cut) + ? CinemachineBlendDefinition.Styles.EaseInOut + : CinemachineBlendDefinition.Styles.Cut; + brain.DefaultBlend = blend; + EditorUtility.SetDirty(brain); + + UpdateDefaultBlendUI(); + } + + #endregion + #region Preset List private void BuildPresetsSection(VisualElement root) { - var section = root.Q("presetsSection"); - if (section == null) return; + presetsSection = root.Q("presetsSection"); + if (presetsSection == null) return; // Header var header = new VisualElement(); @@ -177,19 +278,88 @@ public class CameraManagerEditor : Editor presetsTitleLabel.AddToClassList("presets-title"); header.Add(presetsTitleLabel); - var addBtn = new Button(AddPreset) { text = "+ 프리셋 추가" }; + var addBtn = new Button(AddPreset) + { + text = "+ 프리셋 추가", + tooltip = "씬의 첫 CinemachineCamera를 추가합니다. 아래 영역으로 드래그해서 추가할 수도 있습니다." + }; addBtn.AddToClassList("preset-add-btn"); header.Add(addBtn); - section.Add(header); + presetsSection.Add(header); // Container presetsContainer = new VisualElement(); - section.Add(presetsContainer); + presetsSection.Add(presetsContainer); + + // Drop zone + dropZone = new Label("CinemachineCamera를 여기로 드래그하여 추가"); + dropZone.AddToClassList("preset-drop-zone"); + presetsSection.Add(dropZone); + + SetupDragAndDrop(presetsSection); RebuildPresetList(); } + private void SetupDragAndDrop(VisualElement target) + { + target.RegisterCallback(evt => + { + if (GetDraggedCameras().Count > 0) + { + DragAndDrop.visualMode = DragAndDropVisualMode.Copy; + dropZone?.AddToClassList("preset-drop-zone--active"); + evt.StopPropagation(); + } + }); + + target.RegisterCallback(_ => + { + dropZone?.RemoveFromClassList("preset-drop-zone--active"); + }); + + target.RegisterCallback(_ => + { + dropZone?.RemoveFromClassList("preset-drop-zone--active"); + }); + + target.RegisterCallback(evt => + { + var cams = GetDraggedCameras(); + dropZone?.RemoveFromClassList("preset-drop-zone--active"); + if (cams.Count == 0) return; + + DragAndDrop.AcceptDrag(); + Undo.RecordObject(this.target, "Add Camera Preset (Drop)"); + foreach (var cam in cams) + { + manager.cameraPresets.Add(new CameraManager.CameraPreset(cam)); + } + EditorUtility.SetDirty(this.target); + RebuildPresetList(); + evt.StopPropagation(); + }); + } + + private List GetDraggedCameras() + { + var list = new List(); + foreach (var obj in DragAndDrop.objectReferences) + { + if (obj is CinemachineCamera cc) + { + if (!list.Contains(cc)) list.Add(cc); + } + else if (obj is GameObject go) + { + var cam = go.GetComponent(); + if (cam != null && !list.Contains(cam)) list.Add(cam); + } + } + return list; + } + private void RebuildPresetList() { if (presetsContainer == null || manager == null) return; @@ -226,14 +396,32 @@ public class CameraManagerEditor : Editor var headerRow = new VisualElement(); headerRow.AddToClassList("preset-item-header"); - var indexLabel = new Label($"{index + 1}"); - indexLabel.AddToClassList("preset-index"); - headerRow.Add(indexLabel); + int idx = index; + + var indexField = new IntegerField + { + value = index + 1, + isDelayed = true, + tooltip = "카메라 순서 번호. 숫자를 입력 후 Enter를 누르면 해당 위치로 이동합니다." + }; + indexField.AddToClassList("preset-index-field"); + indexField.RegisterValueChangedCallback(evt => + { + int newIndex = Mathf.Clamp(evt.newValue - 1, 0, manager.cameraPresets.Count - 1); + if (newIndex != idx) + { + MovePreset(idx, newIndex); + } + else if (evt.newValue != idx + 1) + { + indexField.SetValueWithoutNotify(idx + 1); + } + }); + headerRow.Add(indexField); 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"); @@ -250,19 +438,24 @@ public class CameraManagerEditor : Editor } // Up button - var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2" }; + var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2", tooltip = "위로 이동" }; upBtn.AddToClassList("preset-reorder-btn"); upBtn.SetEnabled(index > 0); headerRow.Add(upBtn); // Down button - var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC" }; + var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC", tooltip = "아래로 이동" }; downBtn.AddToClassList("preset-reorder-btn"); downBtn.SetEnabled(index < manager.cameraPresets.Count - 1); headerRow.Add(downBtn); + // Duplicate button + var duplicateBtn = new Button(() => DuplicatePreset(index)) { text = "\u29C9", tooltip = "프리셋 복제" }; + duplicateBtn.AddToClassList("preset-duplicate-btn"); + headerRow.Add(duplicateBtn); + // Delete button - var deleteBtn = new Button(() => DeletePreset(index)) { text = "X" }; + var deleteBtn = new Button(() => DeletePreset(index)) { text = "X", tooltip = "프리셋 삭제" }; deleteBtn.AddToClassList("preset-delete-btn"); headerRow.Add(deleteBtn); @@ -278,11 +471,10 @@ public class CameraManagerEditor : Editor 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; + manager.cameraPresets[idx].virtualCamera = evt.newValue as CinemachineCamera; EditorUtility.SetDirty(target); }); fields.Add(cameraField); @@ -292,11 +484,10 @@ public class CameraManagerEditor : Editor tooltip = "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부", value = preset.allowMouseControl }; - int mi = index; mouseToggle.RegisterValueChangedCallback(evt => { Undo.RecordObject(target, "Toggle Mouse Control"); - manager.cameraPresets[mi].allowMouseControl = evt.newValue; + manager.cameraPresets[idx].allowMouseControl = evt.newValue; EditorUtility.SetDirty(target); }); fields.Add(mouseToggle); @@ -337,6 +528,36 @@ public class CameraManagerEditor : Editor RebuildPresetList(); } + private void MovePreset(int from, int to) + { + if (from == to) return; + if (from < 0 || from >= manager.cameraPresets.Count) return; + if (to < 0 || to >= manager.cameraPresets.Count) return; + + Undo.RecordObject(target, "Move Camera Preset"); + var preset = manager.cameraPresets[from]; + manager.cameraPresets.RemoveAt(from); + manager.cameraPresets.Insert(to, preset); + EditorUtility.SetDirty(target); + RebuildPresetList(); + } + + private void DuplicatePreset(int index) + { + if (index < 0 || index >= manager.cameraPresets.Count) return; + + Undo.RecordObject(target, "Duplicate Camera Preset"); + var source = manager.cameraPresets[index]; + var copy = new CameraManager.CameraPreset(source.virtualCamera) + { + presetName = $"{source.presetName} (복사)", + allowMouseControl = source.allowMouseControl + }; + manager.cameraPresets.Insert(index + 1, copy); + EditorUtility.SetDirty(target); + RebuildPresetList(); + } + #endregion #region Play Mode State diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss index 426b38d8c..455f0ae59 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss +++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss @@ -1,5 +1,54 @@ /* Camera Manager Editor styles */ +.blend-toggle-row { + flex-direction: row; + align-items: center; + padding: 4px 2px; +} + +.blend-toggle-label { + flex-grow: 1; + font-size: 11px; + color: #d4d4d4; +} + +.blend-toggle-btn { + min-width: 160px; + padding: 4px 10px; + border-radius: 4px; + border-width: 0; + font-size: 11px; + -unity-font-style: bold; + background-color: rgba(255, 255, 255, 0.1); + color: #e5e7eb; +} + +.blend-toggle-btn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.blend-toggle-btn:disabled { + opacity: 0.5; +} + +.blend-toggle-btn--cut { + background-color: rgba(234, 179, 8, 0.25); + color: #fde68a; +} + +.blend-toggle-btn--cut:hover { + background-color: rgba(234, 179, 8, 0.4); +} + +.blend-toggle-btn--ease { + background-color: rgba(34, 197, 94, 0.25); + color: #bbf7d0; +} + +.blend-toggle-btn--ease:hover { + background-color: rgba(34, 197, 94, 0.4); +} + .presets-section { margin-top: 12px; } @@ -63,6 +112,18 @@ color: #94a3b8; } +.preset-index-field { + width: 42px; + margin-right: 4px; +} + +.preset-index-field > .unity-integer-field__input { + -unity-text-align: middle-center; + -unity-font-style: bold; + font-size: 11px; + padding: 0 2px; +} + .preset-name-field { flex-grow: 1; margin-left: 4px; @@ -97,6 +158,24 @@ opacity: 0.3; } +.preset-duplicate-btn { + width: 24px; + height: 20px; + padding: 0; + margin: 0 1px; + border-radius: 3px; + border-width: 0; + background-color: rgba(99, 102, 241, 0.15); + color: #a5b4fc; + font-size: 12px; + -unity-text-align: middle-center; +} + +.preset-duplicate-btn:hover { + background-color: rgba(99, 102, 241, 0.35); + color: #c7d2fe; +} + .preset-delete-btn { width: 24px; height: 20px; @@ -126,3 +205,22 @@ font-size: 11px; -unity-font-style: italic; } + +.preset-drop-zone { + margin-top: 8px; + padding: 12px; + border-width: 1px; + border-color: rgba(255, 255, 255, 0.15); + border-radius: 6px; + background-color: rgba(255, 255, 255, 0.03); + -unity-text-align: middle-center; + color: #94a3b8; + font-size: 11px; +} + +.preset-drop-zone--active { + border-color: #6366f1; + background-color: rgba(99, 102, 241, 0.15); + color: #c7d2fe; + -unity-font-style: bold; +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml index 4bbcc0e49..8214e526f 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml +++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml @@ -60,6 +60,14 @@ + + + + + + + +