using DG.Tweening; using System.Collections.Generic; using UnityEngine; using UVC.Data; using UVC.Data.Core; using UVC.Factory.Cameras; using UVC.Factory.Component; namespace UVC.Factory.Alarm { /// /// 특정 설비(FactoryObject)에 연결된 알람 아이콘 UI를 관리하는 클래스입니다. /// 알람이 하나일 때는 단일 아이콘, 여러 개일 때는 클러스터(묶음) 아이콘을 표시합니다. /// 클러스터 아이콘을 클릭하면 개별 알람 아이콘들을 원형으로 펼쳐 보여주는 기능을 담당합니다. /// public class AlarmIconManager : MonoBehaviour { [Header("UI Components")] [Tooltip("알람이 여러 개일 때 표시될 묶음 아이콘 UI입니다.")] [SerializeField] private AlarmClusterIcon clusterView; [Tooltip("알람 아이콘들이 펼쳐질 때 부모가 될 UI 영역입니다.")] [SerializeField] private GameObject expandedView; [Tooltip("개별 알람을 표시할 아이콘의 프리팹입니다.")] [SerializeField] private GameObject alarmSingleIconManagerPrefab; // 개별 알람 아이콘 [Header("UI Layout Settings")] [Tooltip("3D 설비 객체와 UI 아이콘 사이의 수직(Y) 간격입니다.")] [SerializeField] private float objectYOffset = 10f; [Tooltip("아이콘을 펼쳤을 때 개별 아이콘 사이의 간격입니다.")] [SerializeField] private float iconSpacing = 20f; [Tooltip("아이콘을 펼쳤을 때의 중심점 X축 오프셋입니다.")] [SerializeField] private float expandOffsetX = 0f; [Tooltip("아이콘을 펼쳤을 때의 중심점 Y축 오프셋입니다.")] [SerializeField] private float expandOffsetY = 0f; // 이 UI가 따라다닐 3D 설비 객체의 Transform입니다. private Transform targetObject; // 이 UI가 관리하는 모든 알람 데이터의 리스트입니다. private List alarms = new List(); // 현재 클러스터가 펼쳐진 상태인지 여부를 나타내는 플래그입니다. private bool isExpanded = false; // 알람이 하나일 때 사용되는 단일 알람 아이콘의 인스턴스입니다. private AlarmSingleIconManager singleAlarmIcon1 = null; // UI 위치 계산에 필요한 컴포넌트들입니다. private RectTransform rectTransform; private Canvas canvas; // UI 애니메이션을 위한 DoTween 핸들러입니다. private Tweener uiSpacingTweener; // 사용자가 클러스터를 클릭하여 줌인했는지 상태를 추적하는 변수입니다. private bool isZoomIn = false; /// /// MonoBehaviour가 활성화될 때 호출되는 Unity 생명주기 메서드입니다. /// UI 위치 계산에 필요한 RectTransform과 Canvas 컴포넌트를 찾아 저장합니다. /// private void Awake() { rectTransform = GetComponent(); canvas = GetComponentInParent(); } /// /// AlarmIconManager를 초기화합니다. AlarmManager에 의해 호출됩니다. /// /// 알람이 발생한 3D 설비 객체 /// 최초로 추가될 알람 데이터 public void Initialize(FactoryObject target, DataObject initialAlarm) { this.targetObject = target.transform; AddAlarm(initialAlarm); // 첫 알람을 리스트에 추가 if (clusterView == null || expandedView == null || alarmSingleIconManagerPrefab == null) { Debug.LogError("필수 UI 컴포넌트가 할당되지 않았습니다. AlarmUIController를 확인하세요."); return; } // 클러스터 아이콘 클릭 시 OnPointerClick 메서드가 호출되도록 이벤트 핸들러를 등록합니다. clusterView.OnClickHandler += OnPointerClick; // 카메라 높이가 변경될 때마다 OnCameraPositionUpChangedHandler가 호출되도록 등록합니다. FactoryCameraController.Instance.OnCameraPositionUpChanged += OnCameraPositionUpChangedHandler; } /// /// 매 프레임의 마지막에 호출되는 Unity 생명주기 메서드입니다. /// 3D 객체의 위치를 2D 스크린 좌표로 변환하여 UI 아이콘이 객체를 따라다니도록 위치를 업데이트합니다. /// void LateUpdate() { if (targetObject == null || Camera.main == null || canvas == null) { // 필수 객체가 없으면 모든 UI를 비활성화하고 종료합니다. if (gameObject.activeSelf) gameObject.SetActive(false); return; } // 카메라의 정면 방향과 카메라에서 타겟을 향하는 방향 벡터를 계산합니다. Vector3 cameraForward = Camera.main.transform.forward; Vector3 toTarget = (targetObject.position - Camera.main.transform.position).normalized; // 두 벡터의 내적(Dot product)을 계산하여 타겟 객체가 카메라 앞에 있는지 확인합니다. // 내적 값이 0보다 크면 객체가 카메라 시야각 내에 있다는 의미입니다. if (Vector3.Dot(cameraForward, toTarget) > 0) { // 객체가 카메라 앞에 있을 때만 UI를 활성화하고 위치를 업데이트합니다. if (!gameObject.activeSelf) { gameObject.SetActive(true); UpdateView(); // 비활성화 상태였다면 뷰를 다시 활성화합니다. } // 3D 월드 좌표를 2D 스크린 좌표로 변환합니다. Vector3 screenPoint = Camera.main.WorldToScreenPoint(targetObject.position + Vector3.up); // 캔버스의 렌더 모드에 따라 좌표 변환 방식을 다르게 처리합니다. // Canvas Render Mode가 Screen Space - Camera일 경우 if (canvas.renderMode == RenderMode.ScreenSpaceCamera) { Vector2 localPoint; RectTransformUtility.ScreenPointToLocalPointInRectangle(canvas.transform as RectTransform, screenPoint, canvas.worldCamera, out localPoint); localPoint.y += objectYOffset; // Y축 오프셋 적용 rectTransform.localPosition = localPoint; } // Canvas Render Mode가 Screen Space - Overlay일 경우 else { screenPoint.y += objectYOffset; // Y축 오프셋 적용 rectTransform.position = screenPoint; } } else { // 타겟이 카메라 뒤에 있으면 UI를 비활성화합니다. if (gameObject.activeSelf) gameObject.SetActive(false); //if (clusterView.gameObject.activeSelf) clusterView.gameObject.SetActive(false); //if (expandedView.activeSelf) expandedView.SetActive(false); //if (singleAlarmIcon1 != null && singleAlarmIcon1.gameObject.activeSelf) singleAlarmIcon1.gameObject.SetActive(false); } } /// /// 이 매니저가 특정 알람을 이미 관리하고 있는지 확인합니다. /// /// 확인할 알람 데이터 /// 포함하고 있으면 true, 아니면 false public bool ContainsAlarm(DataObject alarm) { return alarms.Exists(a => a.Id == alarm.Id); } /// /// 새로운 알람을 추가합니다. /// /// 추가할 알람 데이터 public void AddAlarm(DataObject alarm) { alarms.Add(alarm); UpdateView(); // 알람 리스트가 변경되었으므로 뷰를 업데이트합니다. } /// /// 기존 알람의 정보를 업데이트합니다. /// /// 업데이트할 알람 데이터 public void UpdateAlarm(DataObject alarm) { // 실제로는 alarmId로 찾아서 업데이트해야 함 int index = alarms.FindIndex(a => a.Id == alarm.Id); if (index >= 0) { foreach (var key in alarm.Keys) { alarms[index][key] = alarm[key]; // 기존 알람 데이터를 새 데이터로 교체 } UpdateView(); // 뷰 업데이트 } else { Debug.LogWarning($"Alarm with ID {alarm.Id} not found for update."); } } /// /// 특정 알람을 제거합니다. /// /// 제거할 알람 데이터 public void RemoveAlarm(DataObject alarm) { // 실제로는 alarmId로 찾아서 지워야 함 alarms.Remove(alarm); UpdateView(); // 뷰 업데이트 } /// /// 현재 알람 상태에 맞게 UI를 갱신합니다. /// 알람 개수와 확장 상태(IsExpanded)에 따라 어떤 UI를 보여줄지 결정합니다. /// private void UpdateView() { if (isExpanded) { // 확장된 상태라면, 개별 아이콘들을 다시 그립니다. (알람 추가/제거 시 대응) ExpandCluster(); } else { if (alarms.Count == 1) { // 알람이 1개이면 단일 아이콘 뷰를 활성화합니다. clusterView.gameObject.SetActive(false); expandedView.SetActive(false); if (singleAlarmIcon1 == null) { // 단일 아이콘이 없으면 새로 생성합니다. singleAlarmIcon1 = Instantiate(alarmSingleIconManagerPrefab, transform).GetComponent(); } singleAlarmIcon1.gameObject.SetActive(true); singleAlarmIcon1.SetData(alarms[0], targetObject); } else if (alarms.Count > 1) // 축소된(기본) 상태 { // 알람이 2개 이상이면 클러스터 뷰를 활성화합니다. clusterView.gameObject.SetActive(true); clusterView.AlarmCount = alarms.Count.ToString(); // 알람 개수 텍스트 업데이트 expandedView.SetActive(false); if (singleAlarmIcon1 != null) singleAlarmIcon1.gameObject.SetActive(false); // 단일 뷰도 꺼야 함 } else // alarms.Count == 0 { // 알람이 없으면 모든 UI를 비활성화하고 이 게임 오브젝트를 파괴합니다. // (AlarmManager에서 처리하므로 여기서는 비활성화만) clusterView.gameObject.SetActive(false); expandedView.SetActive(false); if (singleAlarmIcon1 != null) singleAlarmIcon1.gameObject.SetActive(false); } } } /// /// 클러스터 아이콘이 클릭되었을 때 호출되는 이벤트 핸들러입니다. /// public void OnPointerClick() { isZoomIn = true; // 사용자가 줌인 동작을 시작했음을 기록 if (alarms.Count > 1) { if (singleAlarmIcon1 != null && singleAlarmIcon1.IsDetailViewActive) { singleAlarmIcon1.HideDetail(); // 단일 아이콘 숨김 } // 카메라를 타겟 객체에 포커스하고, 클러스터를 펼칩니다. FactoryCameraController.Instance.FocusOnTarget(targetObject.position, 15.0f); // 예시: 5미터 거리로 줌 ExpandCluster(); } } /// /// 클러스터 뷰를 펼쳐서 개별 알람 아이콘들을 원형으로 배열합니다. /// private void ExpandCluster() { isExpanded = true; clusterView.gameObject.SetActive(false); // 클러스터 뷰는 숨김 expandedView.SetActive(true); // 확장 뷰는 보임 if (singleAlarmIcon1 != null) singleAlarmIcon1.gameObject.SetActive(false); // 재활용을 위해 기존에 생성된 아이콘들을 딕셔너리에 저장합니다. (Key: 알람 ID, Value: 아이콘 인스턴스) var existingIcons = new Dictionary(); foreach (Transform child in expandedView.transform) { AlarmSingleIconManager icon = child.GetComponent(); if (icon != null && icon.Data != null) { if (!existingIcons.ContainsKey(icon.Data.Id)) { existingIcons.Add(icon.Data.Id, icon); } } } var activeIcons = new List(); // 현재 알람 목록을 순회하며 아이콘을 업데이트하거나 새로 생성 foreach (var alarmData in alarms) { AlarmSingleIconManager icon; if (existingIcons.TryGetValue(alarmData.Id, out icon)) { // 기존 아이콘 재사용 icon.SetData(alarmData, targetObject); existingIcons.Remove(alarmData.Id); // 처리된 아이콘은 딕셔너리에서 제거 } else { // 새 아이콘 생성 GameObject iconObj = Instantiate(alarmSingleIconManagerPrefab, expandedView.transform); icon = iconObj.GetComponent(); icon.SetData(alarmData, targetObject); icon.OnDetail = OnDetailSingle; } activeIcons.Add(icon); } // 더 이상 사용되지 않는 기존 아이콘들 삭제 foreach (var icon in existingIcons.Values) { icon.OnDetail = null; Destroy(icon.gameObject); } // alarmSingleIconPrefab의 RectTransform을 가져와 아이콘의 너비를 계산합니다. RectTransform iconRect = alarmSingleIconManagerPrefab.GetComponent(); if (iconRect == null) { Debug.LogError("alarmSingleIconPrefab에 RectTransform 컴포넌트가 없습니다."); return; } // 아이콘들을 원형으로 배치하는 로직 float iconWidth = iconRect.rect.width; // 아이콘의 개수와 너비, 간격을 고려하여 필요한 원주를 계산합니다. float circumference = (iconWidth + iconSpacing) * activeIcons.Count; // 원주를 이용하여 반지름(radius)을 계산합니다. float radius = circumference / (2f * Mathf.PI); // 중심 오프셋을 적용할 Vector3를 생성합니다. Vector3 centerOffset = new Vector3(expandOffsetX, expandOffsetY, 0); for (int i = 0; i < activeIcons.Count; i++) { float angle = i * Mathf.PI * 2f / activeIcons.Count; // 오프셋을 적용하여 아이콘의 위치를 계산합니다. Vector3 pos = centerOffset + new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0) * radius; activeIcons[i].transform.localPosition = pos; } // 상세 보기가 활성화된 아이콘을 찾아 맨 위로 올립니다. foreach (var icon in activeIcons) { if (icon.IsDetailViewActive) { icon.transform.SetAsLastSibling(); break; } } //AnimateUISpace(0f); // 간격을 0으로 애니메이션 } /// /// 개별 알람 아이콘 중 하나가 상세 보기를 활성화했을 때 호출됩니다. /// /// 상세 보기를 활성화한 아이콘 private void OnDetailSingle(AlarmSingleIconManager alarmSingle) { // 다른 모든 아이콘들의 상세 보기는 숨깁니다. foreach (Transform child in expandedView.transform) { AlarmSingleIconManager existingIcon = child.GetComponent(); if (existingIcon != null) { if (existingIcon != alarmSingle) existingIcon.HideDetail(); // 다른 아이콘은 숨김 } } } /// /// 펼쳐진 클러스터를 다시 원래의 묶음 아이콘 상태로 되돌립니다. /// private void CollapseCluster() { isExpanded = false; // 모든 개별 아이콘의 상세 보기를 숨깁니다. foreach (Transform child in expandedView.transform) { AlarmSingleIconManager existingIcon = child.GetComponent(); if (existingIcon != null && existingIcon.IsDetailViewActive) existingIcon.HideDetail(); } if (singleAlarmIcon1 != null && singleAlarmIcon1.IsDetailViewActive) { singleAlarmIcon1.HideDetail(); // 단일 아이콘도 숨김 } UpdateView(); // 뷰를 다시 축소된 상태로 업데이트합니다. } /// /// 현재 관리 중인 알람의 개수를 반환합니다. /// public int GetAlarmCount() => alarms.Count; private void AnimateUISpace(float targetSpacing, float duration = 1.0f) { if (uiSpacingTweener != null && uiSpacingTweener.IsActive() && uiSpacingTweener.IsPlaying()) { uiSpacingTweener.Kill(); } uiSpacingTweener = DOVirtual.Float(objectYOffset, targetSpacing, duration, (value) => { objectYOffset = value; }); } /// /// 카메라 위치가 변경될 때 호출되는 이벤트 핸들러입니다. /// 사용자가 줌 아웃(카메라가 멀어짐)하면 펼쳐진 클러스터를 자동으로 닫습니다. /// private void OnCameraPositionUpChangedHandler(float gap) { if (isZoomIn) { // 줌 인 상태에서 카메라 위치가 변경되면 클러스터를 해제 CollapseCluster(); isZoomIn = false; // 줌 인 상태를 해제합니다. } } private void OnDestroy() { // DoTween 애니메이션이 실행 중이면 중지시킵니다. if (uiSpacingTweener != null && uiSpacingTweener.IsActive() && uiSpacingTweener.IsPlaying()) { uiSpacingTweener.Kill(); } FactoryCameraController.Instance.OnCameraPositionUpChanged -= OnCameraPositionUpChangedHandler; clusterView.OnClickHandler -= OnPointerClick; targetObject = null; alarms.Clear(); alarms = null; canvas = null; if (singleAlarmIcon1 != null) { Destroy(singleAlarmIcon1.gameObject); singleAlarmIcon1 = null; } } } }