diff --git a/Assets/External/Fullscreen/Editor/FullscreenContainerInternal.cs b/Assets/External/Fullscreen/Editor/FullscreenContainerInternal.cs index 52252305..b64ef1b4 100644 --- a/Assets/External/Fullscreen/Editor/FullscreenContainerInternal.cs +++ b/Assets/External/Fullscreen/Editor/FullscreenContainerInternal.cs @@ -74,7 +74,9 @@ namespace FullscreenEditor { /// The ContainerWindow to freeze the repaints. /// Wheter to freeze or unfreeze the container. protected void SetFreezeContainer(ContainerWindow containerWindow, bool freeze) { - containerWindow.InvokeMethod("SetFreezeDisplay", freeze); + // SetFreezeDisplay was removed in Unity 6 - skip if not available + if (containerWindow.HasMethod("SetFreezeDisplay", new[] { typeof(bool) })) + containerWindow.InvokeMethod("SetFreezeDisplay", freeze); } /// Method that will be called just before creating the ContainerWindow for this fullscreen. diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs index 931d0a6d..a0941c41 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs @@ -4,6 +4,7 @@ using UnityRawInput; using System.Linq; using Unity.Cinemachine; using Streamingle; +using KindRetargeting; public class CameraManager : MonoBehaviour, IController { @@ -130,6 +131,11 @@ public class CameraManager : MonoBehaviour, IController public HotkeyCommand hotkey; [System.NonSerialized] public bool isEditingHotkey = false; + // 프리셋별 초기 상태 저장 + [System.NonSerialized] public Vector3 savedPosition; + [System.NonSerialized] public Quaternion savedRotation; + [System.NonSerialized] public bool hasSavedState = false; + public CameraPreset(CinemachineCamera camera) { virtualCamera = camera; @@ -138,6 +144,21 @@ public class CameraManager : MonoBehaviour, IController } public bool IsValid() => virtualCamera != null && hotkey != null; + + public void SaveCurrentState() + { + if (virtualCamera == null) return; + savedPosition = virtualCamera.transform.position; + savedRotation = virtualCamera.transform.rotation; + hasSavedState = true; + } + + public void RestoreSavedState() + { + if (!hasSavedState || virtualCamera == null) return; + virtualCamera.transform.position = savedPosition; + virtualCamera.transform.rotation = savedRotation; + } } #endregion @@ -148,31 +169,49 @@ public class CameraManager : MonoBehaviour, IController #region Fields [SerializeField] public List cameraPresets = new List(); - + [Header("Camera Control Settings")] - private float rotationSensitivity = 2f; - private float panSpeed = 0.02f; - private float zoomSpeed = 0.1f; - private float orbitSpeed = 10f; - + [SerializeField, Range(0.5f, 10f)] private float rotationSensitivity = 2f; + [SerializeField, Range(0.005f, 0.1f)] private float panSpeed = 0.02f; + [SerializeField, Range(0.05f, 0.5f)] private float zoomSpeed = 0.1f; + [SerializeField, Range(1f, 20f)] private float orbitSpeed = 10f; + + [Header("Smoothing")] + [SerializeField, Range(0f, 0.95f)] private float movementSmoothing = 0.1f; + [SerializeField, Range(0f, 0.95f)] private float rotationSmoothing = 0.1f; + + [Header("Zoom Limits")] + [SerializeField] private float minZoomDistance = 0.5f; + [SerializeField] private float maxZoomDistance = 50f; + + [Header("Rotation Target")] + [Tooltip("체크하면 아바타 머리를 자동으로 찾아 회전 중심점으로 사용합니다.")] + [SerializeField] private bool useAvatarHeadAsTarget = true; + [Tooltip("수동으로 회전 중심점을 지정합니다. (useAvatarHeadAsTarget이 false일 때 사용)")] + [SerializeField] private Transform manualRotationTarget; + private CinemachineCamera currentCamera; private InputHandler inputHandler; private CameraPreset currentPreset; - private Vector3 rotationCenter = Vector3.zero; - private float currentOrbitDistance = 0f; - private float currentOrbitHeight = 0f; - private Vector3 rotationStartPosition; - private bool isRotating = false; - - // 초기 카메라 상태 저장 - private Vector3 initialPosition; - private Quaternion initialRotation; - private bool isInitialStateSet = false; - + + // 오빗 카메라 상태 (각도 기반) + private float horizontalAngle; + private float verticalAngle; + private float currentDistance; + private Vector3 focusPoint; + + // 타겟 값 (스무딩용) + private float targetHorizontalAngle; + private float targetVerticalAngle; + private float targetDistance; + private Vector3 targetFocusPoint; + + // 아바타 머리 추적 + private Transform avatarHeadTransform; + // 스트림덱 연동 private StreamDeckServerManager streamDeckManager; - #endregion #region Properties @@ -186,15 +225,24 @@ public class CameraManager : MonoBehaviour, IController InitializeInputHandler(); InitializeRawInput(); InitializeCameraPresets(); - + // StreamDeckServerManager 찾기 - streamDeckManager = FindObjectOfType(); + streamDeckManager = FindAnyObjectByType(); if (streamDeckManager == null) { Debug.LogWarning("[CameraManager] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다."); } } + private void Start() + { + // Start에서 아바타 머리 다시 찾기 (다른 스크립트들이 초기화된 후) + if (useAvatarHeadAsTarget && avatarHeadTransform == null) + { + FindAvatarHead(); + } + } + private void OnDestroy() { if (RawInput.IsRunning) @@ -263,16 +311,71 @@ public class CameraManager : MonoBehaviour, IController { preset.hotkey.InitializeUnityKeys(); } - - Set(0); - - // 초기 카메라 상태 저장 - if (currentCamera != null && !isInitialStateSet) + + // 모든 프리셋의 초기 상태 저장 + foreach (var preset in cameraPresets.Where(p => p?.IsValid() == true)) { - initialPosition = currentCamera.transform.position; - initialRotation = currentCamera.transform.rotation; - isInitialStateSet = true; + if (!preset.hasSavedState) + { + preset.SaveCurrentState(); + } } + + // 아바타 머리 찾기 + FindAvatarHead(); + + Set(0); + } + + private void FindAvatarHead() + { + if (!useAvatarHeadAsTarget) return; + + // CustomRetargetingScript를 가진 아바타 찾기 + var retargetingScripts = FindObjectsByType(FindObjectsSortMode.None); + + foreach (var script in retargetingScripts) + { + if (script == null || !script.gameObject.activeInHierarchy) continue; + + // targetAnimator가 설정되어 있으면 사용 + Animator animator = script.targetAnimator; + + // targetAnimator가 null이면 같은 GameObject의 Animator 시도 + if (animator == null) + { + animator = script.GetComponent(); + } + + if (animator != null) + { + avatarHeadTransform = animator.GetBoneTransform(HumanBodyBones.Head); + if (avatarHeadTransform != null) + { + Debug.Log($"[CameraManager] 아바타 머리를 회전 중심점으로 설정: {avatarHeadTransform.name}"); + return; + } + } + } + + Debug.LogWarning("[CameraManager] 활성화된 아바타의 Head 본을 찾을 수 없습니다. 수동 타겟을 사용하거나 원점을 사용합니다."); + } + + /// + /// 현재 회전 중심점을 반환합니다. + /// 우선순위: 아바타 머리 > 수동 타겟 > 원점 + /// + private Vector3 GetRotationCenter() + { + if (useAvatarHeadAsTarget && avatarHeadTransform != null) + { + return avatarHeadTransform.position; + } + else if (manualRotationTarget != null) + { + return manualRotationTarget.position; + } + return Vector3.zero; } @@ -312,12 +415,36 @@ public class CameraManager : MonoBehaviour, IController #endregion #region Camera Controls + + /// + /// 현재 카메라 위치에서 각도와 거리를 초기화합니다. + /// + private void InitializeOrbitState() + { + if (currentCamera == null) return; + + focusPoint = GetRotationCenter(); + targetFocusPoint = focusPoint; + + Vector3 direction = currentCamera.transform.position - focusPoint; + currentDistance = direction.magnitude; + targetDistance = currentDistance; + + if (currentDistance > 0.01f) + { + direction.Normalize(); + horizontalAngle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg; + verticalAngle = Mathf.Asin(Mathf.Clamp(direction.y, -1f, 1f)) * Mathf.Rad2Deg; + } + + targetHorizontalAngle = horizontalAngle; + targetVerticalAngle = verticalAngle; + } + private void HandleCameraControls() { if (!IsValidSetup) return; - - var virtualCamera = currentCamera; - if (virtualCamera == null) return; + if (currentCamera == null) return; // Alt+Q로 초기 위치로 복원 if (Input.GetKey(KeyCode.LeftAlt) && Input.GetKeyDown(KeyCode.Q)) @@ -326,141 +453,166 @@ public class CameraManager : MonoBehaviour, IController return; } - HandleRotation(virtualCamera); - HandlePanning(virtualCamera); - HandleZooming(virtualCamera); - HandleOrbiting(virtualCamera); + HandleInput(); + UpdateCameraPosition(); } - private void HandleRotation(CinemachineCamera virtualCamera) + private void HandleInput() { - Transform cameraTransform = virtualCamera.transform; - - if (inputHandler.IsRightMouseHeld()) + // 입력 우선순위 처리: Orbit > AltRightZoom > Zoom > Rotation > Panning + if (inputHandler.IsOrbitActive()) { - if (!isRotating) - { - // 회전 시작 시 현재 상태 저장 - isRotating = true; - rotationStartPosition = cameraTransform.position; - rotationCenter = new Vector3(0f, rotationStartPosition.y, 0f); - - // 현재 카메라의 회전 중심점으로부터의 거리와 높이 계산 - Vector3 toCamera = cameraTransform.position - rotationCenter; - currentOrbitDistance = new Vector3(toCamera.x, 0f, toCamera.z).magnitude; - currentOrbitHeight = toCamera.y; - } + HandleOrbiting(); + } + else if (inputHandler.IsCtrlRightZoomActive()) + { + HandleCtrlRightZoom(); + } + else if (inputHandler.IsZoomActive()) + { + HandleDragZoom(); + } + else if (inputHandler.IsRightMouseHeld()) + { + HandleRotation(); + } + else if (inputHandler.IsMiddleMouseHeld()) + { + HandlePanning(); + } - Vector2 lookDelta = inputHandler.GetLookDelta(); - if (lookDelta.sqrMagnitude < float.Epsilon) return; - - // 현재 회전값을 오일러 각도로 가져오기 - Vector3 currentEuler = cameraTransform.eulerAngles; - - // Y축 회전만 적용 (수평 회전) - float newY = currentEuler.y + lookDelta.x * rotationSensitivity; - - // 회전 적용 (X축 회전은 0으로 고정) - Quaternion targetRotation = Quaternion.Euler(0f, newY, 0f); - - // 오비탈 회전을 위한 새로운 위치 계산 - Vector3 orbitPosition = targetRotation * Vector3.back * currentOrbitDistance; - orbitPosition.y = currentOrbitHeight; // 높이 유지 - - // 회전 중심점을 기준으로 새로운 위치 설정 - cameraTransform.position = rotationCenter + orbitPosition; - cameraTransform.rotation = targetRotation; + // 휠 줌은 항상 처리 + HandleWheelZoom(); + } + + /// + /// 우클릭 드래그: 수평 + 수직 회전 (현재 비활성화 - Alt+우클릭 사용) + /// + private void HandleRotation() + { + // 우클릭만으로는 회전하지 않음 - Alt+우클릭(Orbit)으로 대체 + } + + /// + /// Alt + 우클릭: 자유 궤도 회전 (X, Y축) + /// + private void HandleOrbiting() + { + Vector2 delta = inputHandler.GetLookDelta(); + if (delta.sqrMagnitude < float.Epsilon) return; + + // 회전 속도 + targetHorizontalAngle += delta.x * rotationSensitivity; + targetVerticalAngle -= delta.y * rotationSensitivity; + targetVerticalAngle = Mathf.Clamp(targetVerticalAngle, -80f, 80f); + } + + /// + /// 휠클릭 드래그: 패닝 (초점 이동) + /// + private void HandlePanning() + { + Vector2 delta = inputHandler.GetLookDelta(); + if (delta.sqrMagnitude < float.Epsilon) return; + + // 거리에 비례하여 패닝 속도 조절 - 0.2배 + float speedMultiplier = targetDistance * panSpeed * 0.175f; + + // 카메라의 로컬 축 기준으로 초점 이동 + Vector3 right = currentCamera.transform.right; + Vector3 up = currentCamera.transform.up; + + Vector3 panOffset = (-right * delta.x - up * delta.y) * speedMultiplier; + targetFocusPoint += panOffset; + } + + /// + /// 마우스 휠: 줌 + /// + private void HandleWheelZoom() + { + float scroll = inputHandler.GetZoomDelta(); + if (Mathf.Abs(scroll) < 0.01f) return; + + // 거리에 비례하여 줌 속도 조절 (더 자연스러운 느낌) - 0.4배 + float zoomDelta = scroll * zoomSpeed * targetDistance * 0.4f; + targetDistance -= zoomDelta; + targetDistance = Mathf.Clamp(targetDistance, minZoomDistance, maxZoomDistance); + } + + /// + /// Ctrl + 좌클릭 드래그: 줌 + /// + private void HandleDragZoom() + { + Vector2 delta = inputHandler.GetLookDelta(); + if (delta.sqrMagnitude < float.Epsilon) return; + + float zoomDelta = delta.y * zoomSpeed * targetDistance * 0.5f; + targetDistance -= zoomDelta; + targetDistance = Mathf.Clamp(targetDistance, minZoomDistance, maxZoomDistance); + } + + /// + /// Ctrl + 우클릭 드래그: 줌 (밀기 = 확대, 당기기 = 축소) + /// + private void HandleCtrlRightZoom() + { + Vector2 delta = inputHandler.GetLookDelta(); + if (delta.sqrMagnitude < float.Epsilon) return; + + // 위로 밀면 확대 (거리 감소), 아래로 밀면 축소 (거리 증가) + float zoomDelta = delta.y * zoomSpeed * targetDistance * 0.5f; + targetDistance -= zoomDelta; + targetDistance = Mathf.Clamp(targetDistance, minZoomDistance, maxZoomDistance); + } + + /// + /// 스무딩을 적용하여 카메라 위치 업데이트 + /// + private void UpdateCameraPosition() + { + float dt = Time.deltaTime; + + // 스무딩 적용 + if (movementSmoothing > 0.01f) + { + float smoothSpeed = (1f - movementSmoothing) * 15f; + horizontalAngle = Mathf.Lerp(horizontalAngle, targetHorizontalAngle, dt * smoothSpeed); + verticalAngle = Mathf.Lerp(verticalAngle, targetVerticalAngle, dt * smoothSpeed); + currentDistance = Mathf.Lerp(currentDistance, targetDistance, dt * smoothSpeed); + focusPoint = Vector3.Lerp(focusPoint, targetFocusPoint, dt * smoothSpeed); } else { - isRotating = false; + horizontalAngle = targetHorizontalAngle; + verticalAngle = targetVerticalAngle; + currentDistance = targetDistance; + focusPoint = targetFocusPoint; } - } - private void HandlePanning(CinemachineCamera virtualCamera) - { - if (!inputHandler.IsMiddleMouseHeld()) return; + // 구면 좌표계에서 카메라 위치 계산 + float horizontalRad = horizontalAngle * Mathf.Deg2Rad; + float verticalRad = verticalAngle * Mathf.Deg2Rad; - Vector2 panDelta = inputHandler.GetLookDelta(); - if (panDelta.sqrMagnitude < float.Epsilon) return; + Vector3 offset = new Vector3( + Mathf.Sin(horizontalRad) * Mathf.Cos(verticalRad), + Mathf.Sin(verticalRad), + Mathf.Cos(horizontalRad) * Mathf.Cos(verticalRad) + ) * currentDistance; - Transform cameraTransform = virtualCamera.transform; - - // 이동 적용 (카메라의 right와 up 방향으로 이동) - Vector3 right = cameraTransform.right * -panDelta.x * panSpeed; - Vector3 up = cameraTransform.up * -panDelta.y * panSpeed; - - cameraTransform.position += right + up; - } - - private void HandleZooming(CinemachineCamera virtualCamera) - { - if (inputHandler.IsZoomActive()) - { - // Ctrl + 좌클릭으로 줌 - Vector2 lookDelta = inputHandler.GetLookDelta(); - if (lookDelta.sqrMagnitude < float.Epsilon) return; - - Transform cameraTransform = virtualCamera.transform; - Vector3 forward = cameraTransform.forward * lookDelta.y * zoomSpeed * 10f; - cameraTransform.position += forward; - } - else - { - // 마우스 휠로 줌 - float zoomDelta = inputHandler.GetZoomDelta(); - if (Mathf.Abs(zoomDelta) <= 0.1f) return; - - Transform cameraTransform = virtualCamera.transform; - Vector3 forward = cameraTransform.forward * zoomDelta * zoomSpeed; - cameraTransform.position += forward; - } - } - - private void HandleOrbiting(CinemachineCamera virtualCamera) - { - if (!inputHandler.IsOrbitActive()) return; - - Vector2 orbitDelta = inputHandler.GetLookDelta(); - if (orbitDelta.sqrMagnitude < float.Epsilon) return; - - Transform cameraTransform = virtualCamera.transform; - - // 현재 회전값을 오일러 각도로 가져오기 - Vector3 currentEuler = cameraTransform.eulerAngles; - - // X축 회전값을 -80도에서 80도 사이로 제한하기 위해 360도 형식에서 변환 - float currentX = currentEuler.x; - if (currentX > 180f) currentX -= 360f; - - // 새로운 회전값 계산 - float newX = currentX - orbitDelta.y * orbitSpeed; - float newY = currentEuler.y + orbitDelta.x * orbitSpeed; - - // X축 회전 제한 (-80도 ~ 80도) - newX = Mathf.Clamp(newX, -80f, 80f); - - // 회전 적용 - Quaternion targetRotation = Quaternion.Euler(newX, newY, 0f); - cameraTransform.rotation = targetRotation; - - // 원점으로부터의 거리 유지 - float distance = cameraTransform.position.magnitude; - - // 새로운 위치 계산 (원점으로부터의 거리 유지) - Vector3 newPosition = targetRotation * Vector3.back * distance; - cameraTransform.position = newPosition; + currentCamera.transform.position = focusPoint + offset; + currentCamera.transform.LookAt(focusPoint); } private void RestoreInitialCameraState() { - if (!isInitialStateSet || currentCamera == null) return; + if (currentPreset == null || !currentPreset.hasSavedState) return; - currentCamera.transform.position = initialPosition; - currentCamera.transform.rotation = initialRotation; - - // 회전 중심점 초기화 - rotationCenter = new Vector3(0f, initialPosition.y, 0f); + currentPreset.RestoreSavedState(); + + // 각도 상태 다시 초기화 + InitializeOrbitState(); } #endregion @@ -494,15 +646,18 @@ public class CameraManager : MonoBehaviour, IController currentPreset = newPreset; UpdateCameraPriorities(newPreset.virtualCamera); - + + // 오빗 상태 초기화 (각도, 거리 계산) + InitializeOrbitState(); + OnCameraChanged?.Invoke(oldPreset, newPreset); - + // 스트림덱에 카메라 변경 알림 전송 if (streamDeckManager != null) { streamDeckManager.NotifyCameraChanged(); } - + Debug.Log($"[CameraManager] 카메라 전환 완료: {newCameraName}"); } diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Input/InputHandler.cs b/Assets/Scripts/Streamingle/StreamingleControl/Input/InputHandler.cs index 3a08eb81..0d76cdec 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Input/InputHandler.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Input/InputHandler.cs @@ -8,14 +8,28 @@ public class InputHandler : MonoBehaviour private bool isMiddleMouseHeld; private bool isOrbitActive; private bool isZoomActive; - + private bool isCtrlRightZoomActive; + + // 현재 활성화된 입력 모드 (충돌 방지) + private InputMode currentMode = InputMode.None; + + private enum InputMode + { + None, + Orbit, // Alt + 우클릭 또는 Alt + 좌클릭 + Zoom, // Ctrl + 좌클릭 + CtrlRightZoom, // Ctrl + 우클릭 + Rotation, // 우클릭 + Pan // 휠클릭 + } + // 카메라 컨트롤 시스템 참조 private CameraControlSystem cameraControlSystem; private void Start() { // CameraControlSystem 찾기 - cameraControlSystem = FindObjectOfType(); + cameraControlSystem = FindAnyObjectByType(); if (cameraControlSystem == null) { Debug.LogWarning("[InputHandler] CameraControlSystem을 찾을 수 없습니다."); @@ -24,11 +38,72 @@ public class InputHandler : MonoBehaviour private void Update() { - // 마우스 버튼 상태 업데이트 - isRightMouseHeld = Input.GetMouseButton(1); - isMiddleMouseHeld = Input.GetMouseButton(2); - isOrbitActive = Input.GetKey(KeyCode.LeftAlt) && Input.GetMouseButton(0); // Alt + 좌클릭으로 궤도 회전 - isZoomActive = Input.GetKey(KeyCode.LeftControl) && Input.GetMouseButton(0); // Ctrl + 좌클릭으로 줌 + // 마우스 버튼 Raw 상태 + bool leftMouse = Input.GetMouseButton(0); + bool rightMouse = Input.GetMouseButton(1); + bool middleMouse = Input.GetMouseButton(2); + bool altKey = Input.GetKey(KeyCode.LeftAlt) || Input.GetKey(KeyCode.RightAlt); + bool ctrlKey = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); + + // 모든 마우스 버튼이 해제되면 모드 리셋 + if (!leftMouse && !rightMouse && !middleMouse) + { + currentMode = InputMode.None; + } + + // 입력 우선순위에 따라 모드 결정 (이미 활성화된 모드가 없을 때만) + if (currentMode == InputMode.None) + { + if (altKey && (rightMouse || leftMouse)) + { + currentMode = InputMode.Orbit; + } + else if (ctrlKey && rightMouse) + { + currentMode = InputMode.CtrlRightZoom; + } + else if (ctrlKey && leftMouse) + { + currentMode = InputMode.Zoom; + } + else if (rightMouse) + { + currentMode = InputMode.Rotation; + } + else if (middleMouse) + { + currentMode = InputMode.Pan; + } + } + + // 현재 모드에 따라 상태 설정 + isOrbitActive = (currentMode == InputMode.Orbit) && (rightMouse || leftMouse) && altKey; + isZoomActive = (currentMode == InputMode.Zoom) && leftMouse && ctrlKey; + isCtrlRightZoomActive = (currentMode == InputMode.CtrlRightZoom) && rightMouse && ctrlKey; + isRightMouseHeld = (currentMode == InputMode.Rotation) && rightMouse; + isMiddleMouseHeld = (currentMode == InputMode.Pan) && middleMouse; + + // 모드가 해제되면 None으로 전환 + if (currentMode == InputMode.Orbit && ((!rightMouse && !leftMouse) || !altKey)) + { + currentMode = InputMode.None; + } + else if (currentMode == InputMode.CtrlRightZoom && (!rightMouse || !ctrlKey)) + { + currentMode = InputMode.None; + } + else if (currentMode == InputMode.Zoom && (!leftMouse || !ctrlKey)) + { + currentMode = InputMode.None; + } + else if (currentMode == InputMode.Rotation && !rightMouse) + { + currentMode = InputMode.None; + } + else if (currentMode == InputMode.Pan && !middleMouse) + { + currentMode = InputMode.None; + } // 마우스 위치 업데이트 lastMousePosition = Input.mousePosition; @@ -39,6 +114,12 @@ public class InputHandler : MonoBehaviour public bool IsMiddleMouseHeld() => isMiddleMouseHeld; public bool IsOrbitActive() => isOrbitActive; public bool IsZoomActive() => isZoomActive; + public bool IsCtrlRightZoomActive() => isCtrlRightZoomActive; + + /// + /// 현재 어떤 입력 모드도 활성화되지 않았는지 확인합니다. + /// + public bool IsIdle() => currentMode == InputMode.None; public Vector2 GetLookDelta() {