using System; using System.Collections; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem.UI; using UnityEngine.UI; /// /// 채팅창의 겉모습. uGUI 계층을 런타임에 전부 코드로 만든다. /// /// 씬이나 프리팹에 UI 를 넣지 않는 이유: /// - 이 프로젝트는 캐릭터도 런타임에 로드한다. UI 만 씬에 박아두면 결이 어긋난다. /// - .unity 파일은 병합이 사실상 불가능해서 손대는 비용이 크다. /// - TextMeshPro Essentials 가 임포트돼 있지 않아 TMP 프리팹은 글자가 안 나온다. /// /// 이 클래스는 대화 내용이 어디서 오는지 모른다. Submitted 로 알리고, /// AppendUser / BeginResponse / AppendDelta 로 지시받기만 한다. /// public class ChatWindowUI : MonoBehaviour { const float HeaderHeight = 34f; const float InputRowHeight = 46f; const float SendButtonWidth = 58f; [Header("크기")] [Tooltip("채팅창 크기(픽셀). 창은 물리 픽셀 기준이라 고DPI 모니터에서는 uiScale 을 올린다")] [SerializeField] Vector2 panelSize = new Vector2(360f, 420f); [Tooltip("UI 전체 배율. 고DPI 모니터에서 글자가 작으면 올린다")] [Range(0.75f, 2.5f)] [SerializeField] float uiScale = 1f; [Header("글자")] [SerializeField] int messageFontSize = 15; [SerializeField] int inputFontSize = 15; [Header("문구")] [SerializeField] string titleText = "무슨 일이야?"; [SerializeField] string placeholderText = "메시지를 입력하고 Enter"; Canvas canvas; RectTransform panel; RectTransform content; ScrollRect scroll; InputField inputField; Button sendButton; Text sendLabel; bool responding; Text streamingBubble; bool responseHasText; // 이번 응답에서 글자가 한 번이라도 왔는지 bool scrollPending; /// 설정 겹판. 채팅 패널 안에 얹혀 있어 히트테스트와 배치를 공유한다. public ChatSettingsUI Settings { get; private set; } /// 사용자가 Enter 를 누르거나 보내기를 눌렀을 때. public event Action Submitted; /// 닫기 버튼을 눌렀을 때. public event Action CloseRequested; /// 머리말의 톱니를 눌렀을 때. 설정 내용을 아는 쪽이 응답한다. public event Action SettingsRequested; /// 응답을 기다리는 중에 보내기 버튼(이때는 "취소")을 눌렀을 때. public event Action CancelRequested; public bool IsOpen => panel != null && panel.gameObject.activeSelf; /// 패널의 화면상 크기(픽셀). 배치 계산에 쓴다. public Vector2 PanelPixelSize => panelSize * (canvas != null ? canvas.scaleFactor : 1f); void Awake() { ChatUiBuilder.EnsureEventSystem(); Build(); panel.gameObject.SetActive(false); } // ------------------------------------------------------------------ 공개 조작 public void Open() { if (panel == null) return; panel.gameObject.SetActive(true); FocusInput(); } public void Close() { if (panel == null) return; Settings.Close(); // 포커스를 쥔 채로 숨기면 EventSystem 이 사라진 오브젝트를 계속 선택 상태로 들고 있다. if (inputField != null) inputField.DeactivateInputField(); if (EventSystem.current != null) EventSystem.current.SetSelectedGameObject(null); panel.gameObject.SetActive(false); } /// 입력칸에 커서를 둔다. 창을 연 직후와 응답이 끝난 뒤에 부른다. public void FocusInput() { if (inputField == null || !IsOpen) return; // 설정 화면이 덮고 있을 때 대화 입력칸을 잡으면 설정의 입력칸에서 커서를 빼앗는다. if (Settings != null && Settings.IsOpen) return; if (EventSystem.current != null) { EventSystem.current.SetSelectedGameObject(inputField.gameObject); } inputField.ActivateInputField(); } /// OS 커서가 채팅창 위에 있는지. 클릭 통과 히트테스트가 이걸 묻는다. public bool ContainsScreenPoint(Vector2 screenPoint) { if (!IsOpen || panel == null) return false; // 오버레이 캔버스는 카메라가 없으므로 null 을 넘긴다. return RectTransformUtility.RectangleContainsScreenPoint(panel, screenPoint, null); } /// 패널 왼쪽 아래 모서리를 화면 좌표에 놓는다. 화면 밖으로 나가지 않게 제한한다. public void SetPanelScreenPosition(Vector2 bottomLeft) { if (panel == null || canvas == null) return; Vector2 size = PanelPixelSize; float x = Mathf.Clamp(bottomLeft.x, 0f, Mathf.Max(0f, Screen.width - size.x)); float y = Mathf.Clamp(bottomLeft.y, 0f, Mathf.Max(0f, Screen.height - size.y)); // 캔버스가 ConstantPixelSize 라 로컬 단위 = 화면 픽셀 / scaleFactor 다. panel.anchoredPosition = new Vector2(x, y) / canvas.scaleFactor; } // ------------------------------------------------------------------ 대화 표시 public void AppendUserMessage(string text) { AddBubble(text, ChatUiTheme.UserBubble, TextAnchor.UpperRight); ScrollToBottom(); } /// 응답용 빈 말풍선을 만든다. 델타가 여기에 쌓인다. public void BeginResponse() { responseHasText = false; streamingBubble = AddBubble("·", ChatUiTheme.AssistantBubble, TextAnchor.UpperLeft); SetResponding(true); ScrollToBottom(); StartCoroutine(AnimateWaiting()); } public void AppendResponseDelta(string delta) { if (streamingBubble == null) return; // 첫 글자가 오면 기다림 표시를 걷어낸다. 이 플래그가 애니메이션도 멈춘다. if (!responseHasText) { responseHasText = true; streamingBubble.text = string.Empty; } streamingBubble.text += delta; ScrollToBottom(); } /// /// 답이 오기 전까지 점을 굴린다. /// /// 이게 없으면 멈춘 것과 구분이 안 된다. 특히 사고(thinking)를 켠 모델은 /// 생각하는 동안 델타를 보내긴 하지만 그 안의 글자가 비어 있어서, 화면에는 /// 몇 초에서 몇십 초 동안 아무 변화가 없다. /// IEnumerator AnimateWaiting() { float startedAt = Time.realtimeSinceStartup; int dots = 0; while (streamingBubble != null && !responseHasText) { dots = dots % 3 + 1; // 잠깐이면 점만, 길어지면 왜 기다리는지 알려준다. bool slow = Time.realtimeSinceStartup - startedAt > 4f; streamingBubble.text = (slow ? "생각하는 중" : string.Empty) + new string('·', dots); yield return new WaitForSecondsRealtime(0.35f); } } public void EndResponse() { if (streamingBubble != null && !responseHasText) { streamingBubble.text = "(빈 응답)"; } responseHasText = true; // 남아 있는 애니메이션 코루틴을 멈춘다 streamingBubble = null; SetResponding(false); ScrollToBottom(); FocusInput(); } /// 실패를 말풍선으로 보여준다. 진행 중이던 응답 풍선이 있으면 그걸 바꿔 쓴다. public void ShowError(string message) { if (streamingBubble != null) { streamingBubble.text = message; var image = streamingBubble.transform.parent.GetComponent(); if (image != null) image.color = ChatUiTheme.ErrorBubble; responseHasText = true; // 남아 있는 애니메이션 코루틴을 멈춘다 streamingBubble = null; } else { AddBubble(message, ChatUiTheme.ErrorBubble, TextAnchor.UpperLeft); } SetResponding(false); ScrollToBottom(); FocusInput(); } /// 안내용 회색 말풍선. 오류가 아니라 알려주기만 할 때 쓴다. public void ShowNotice(string message) { AddBubble(message, ChatUiTheme.AssistantBubble, TextAnchor.UpperLeft); ScrollToBottom(); } public void ClearMessages() { if (content == null) return; for (int i = content.childCount - 1; i >= 0; i--) { Destroy(content.GetChild(i).gameObject); } streamingBubble = null; } /// /// 응답을 기다리는 동안의 화면 상태. /// /// 보내기 버튼을 비활성으로 두지 않고 "취소"로 바꾼다. 응답이 늦거나 끊겼을 때 /// 버튼이 전부 죽어 있으면 사용자가 창을 닫는 것 말고 할 수 있는 게 없다. /// void SetResponding(bool value) { responding = value; if (inputField != null) inputField.interactable = !value; if (sendLabel != null) sendLabel.text = value ? "취소" : "보내기"; } /// 사용자가 기다리다 취소했을 때. 말풍선을 정리하고 입력을 되살린다. public void CancelResponse() { if (streamingBubble != null) { streamingBubble.text = responseHasText ? streamingBubble.text + "\n(취소됨)" : "(취소됨)"; } responseHasText = true; // 남아 있는 애니메이션 코루틴을 멈춘다 streamingBubble = null; SetResponding(false); ScrollToBottom(); FocusInput(); } Text AddBubble(string text, Color background, TextAnchor alignment) { // 위치와 크기는 부모의 VerticalLayoutGroup 이 정한다. 여기서 앵커를 잡아봐야 덮인다. var row = ChatUiBuilder.NewRect("Bubble", content); var image = row.gameObject.AddComponent(); image.sprite = ChatUiTheme.RoundedSmall; image.type = Image.Type.Sliced; image.color = background; var layout = row.gameObject.AddComponent(); // 말한 쪽에 따라 안쪽 여백을 다르게 줘서 시선이 좌우로 갈리게 한다. // 풍선 폭 자체를 글자에 맞추려면 계층이 한 겹 더 필요한데, 좁은 창에서는 // 색과 정렬만으로도 충분히 구분된다. bool fromUser = alignment == TextAnchor.UpperRight; layout.padding = fromUser ? new RectOffset(34, 10, 7, 7) : new RectOffset(10, 34, 7, 7); layout.childControlWidth = true; layout.childControlHeight = true; layout.childForceExpandWidth = true; layout.childForceExpandHeight = false; // 여기에 ContentSizeFitter 를 또 붙이지 않는다. 부모 Content 의 // VerticalLayoutGroup 이 childControlHeight 로 이 VLG 의 preferredHeight 를 // 읽어 높이를 정한다. 둘 다 있으면 서로 덮어써서 높이가 튄다. var label = ChatUiBuilder.NewText("Label", row, text, messageFontSize, ChatUiTheme.PrimaryText); label.alignment = alignment; label.horizontalOverflow = HorizontalWrapMode.Wrap; label.verticalOverflow = VerticalWrapMode.Overflow; return label; } /// /// 맨 아래로 스크롤을 예약한다. 델타가 초당 수십 번 오므로 이미 예약돼 있으면 /// 다시 걸지 않는다. 한 번의 코루틴이 그때까지 쌓인 내용을 모두 반영한다. /// void ScrollToBottom() { if (!isActiveAndEnabled || scrollPending) return; scrollPending = true; StartCoroutine(ScrollToBottomNextFrame()); } /// /// 레이아웃이 확정된 뒤에 맨 아래로 내린다. /// 말풍선 높이는 레이아웃 계산이 이번 프레임 끝에 돌기 때문에, 같은 프레임에서 /// 스크롤 위치를 만지면 이전 높이 기준으로 계산돼 어긋난다. /// IEnumerator ScrollToBottomNextFrame() { yield return null; scrollPending = false; if (scroll == null || content == null) yield break; Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(content); scroll.verticalNormalizedPosition = 0f; } // ------------------------------------------------------------------ 계층 만들기 void Build() { var canvasGo = new GameObject("ChatCanvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); canvasGo.transform.SetParent(transform, false); canvas = canvasGo.GetComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 200; // 디버그 HUD(IMGUI)보다 아래지만 3D 캐릭터보다는 위 var scaler = canvasGo.GetComponent(); // 이 프로젝트는 캐릭터 위치, 창 발판, 화면 제한을 전부 물리 픽셀로 계산한다. // UI 도 같은 단위를 써야 캐릭터 옆에 붙이는 계산이 어긋나지 않는다. scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize; scaler.scaleFactor = uiScale; BuildPanel(); } void BuildPanel() { panel = ChatUiBuilder.NewRect("Panel", canvas.transform as RectTransform); panel.anchorMin = Vector2.zero; panel.anchorMax = Vector2.zero; panel.pivot = Vector2.zero; panel.sizeDelta = panelSize; panel.anchoredPosition = new Vector2(40f, 40f); var background = panel.gameObject.AddComponent(); background.sprite = ChatUiTheme.RoundedPanel; background.type = Image.Type.Sliced; background.color = ChatUiTheme.PanelBackground; BuildHeader(); BuildScrollArea(); BuildInputRow(); // 마지막에 만들어야 형제 순서상 맨 위에 그려진다. uGUI 는 계층 순서대로 그린다. Settings = new ChatSettingsUI(panel); Settings.CloseRequested += CloseSettings; } void BuildHeader() { var header = ChatUiBuilder.NewRect("Header", panel); header.anchorMin = new Vector2(0f, 1f); header.anchorMax = new Vector2(1f, 1f); header.pivot = new Vector2(0.5f, 1f); header.sizeDelta = new Vector2(0f, HeaderHeight); header.anchoredPosition = Vector2.zero; var title = ChatUiBuilder.NewText("Title", header, titleText, 13, ChatUiTheme.DimText); title.alignment = TextAnchor.MiddleLeft; var titleRect = title.rectTransform; titleRect.anchorMin = Vector2.zero; titleRect.anchorMax = Vector2.one; titleRect.offsetMin = new Vector2(14f, 0f); titleRect.offsetMax = new Vector2(-HeaderHeight * 2f, 0f); // 글자는 OS 글꼴(맑은 고딕)에서 나오므로 그 글꼴에 있는 문자만 쓴다. // ⚙ 나 ✕ 같은 기호는 한글 글꼴에 없는 경우가 있고, 없으면 버튼이 빈칸으로 보인다. // × 는 Latin-1 이라 어떤 글꼴에나 있고, 나머지는 그냥 한글로 쓴다. var settingsButton = HeaderButton("Settings", header, "설정", 12, -HeaderHeight); settingsButton.onClick.AddListener(() => SettingsRequested?.Invoke()); var close = HeaderButton("Close", header, "×", 20, -4f); close.onClick.AddListener(() => CloseRequested?.Invoke()); // 머리말과 대화 영역을 가르는 선. 배경이 반투명이라 경계가 없으면 뭉개진다. var divider = ChatUiBuilder.NewImage("Divider", header, null, new Color(1f, 1f, 1f, 0.08f)); var dividerRect = divider.rectTransform; dividerRect.anchorMin = Vector2.zero; dividerRect.anchorMax = new Vector2(1f, 0f); dividerRect.pivot = new Vector2(0.5f, 0f); dividerRect.sizeDelta = new Vector2(-16f, 1f); dividerRect.anchoredPosition = Vector2.zero; } static Button HeaderButton(string name, Transform parent, string glyph, int fontSize, float rightOffset) { var button = ChatUiBuilder.NewButton(name, parent, glyph, fontSize, Color.clear, ChatUiTheme.DimText); var rect = button.GetComponent(); rect.anchorMin = new Vector2(1f, 0.5f); rect.anchorMax = new Vector2(1f, 0.5f); rect.pivot = new Vector2(1f, 0.5f); rect.sizeDelta = new Vector2(HeaderHeight, HeaderHeight); rect.anchoredPosition = new Vector2(rightOffset, 0f); return button; } void BuildScrollArea() { var scrollRect = ChatUiBuilder.NewRect("Scroll", panel); scrollRect.anchorMin = Vector2.zero; scrollRect.anchorMax = Vector2.one; scrollRect.offsetMin = new Vector2(6f, InputRowHeight); scrollRect.offsetMax = new Vector2(-6f, -HeaderHeight); scroll = scrollRect.gameObject.AddComponent(); scroll.horizontal = false; scroll.vertical = true; scroll.movementType = ScrollRect.MovementType.Clamped; scroll.scrollSensitivity = 26f; var viewport = ChatUiBuilder.NewRect("Viewport", scrollRect); ChatUiBuilder.Stretch(viewport); // RectMask2D 는 Image 없이도 잘라낸다. Mask 와 달리 스텐실을 쓰지 않아 // 투명 창의 알파를 건드리지 않는다. viewport.gameObject.AddComponent(); content = ChatUiBuilder.NewRect("Content", viewport); content.anchorMin = new Vector2(0f, 1f); content.anchorMax = new Vector2(1f, 1f); content.pivot = new Vector2(0.5f, 1f); content.sizeDelta = Vector2.zero; ChatUiBuilder.MakeVerticalList(content, new RectOffset(4, 4, 8, 8), 6f); var fitter = content.gameObject.AddComponent(); fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; scroll.viewport = viewport; scroll.content = content; } void BuildInputRow() { var row = ChatUiBuilder.NewRect("InputRow", panel); row.anchorMin = Vector2.zero; row.anchorMax = new Vector2(1f, 0f); row.pivot = new Vector2(0.5f, 0f); row.sizeDelta = new Vector2(0f, InputRowHeight); row.anchoredPosition = Vector2.zero; inputField = ChatUiBuilder.NewInputField("Input", row, placeholderText, inputFontSize); inputField.characterLimit = 2000; // onEndEdit 은 포커스를 잃기만 해도 불린다. Enter 로 보내는 것만 원하므로 onSubmit 을 쓴다. inputField.onSubmit.AddListener(SubmitCurrent); var fieldRect = inputField.GetComponent(); fieldRect.anchorMin = Vector2.zero; fieldRect.anchorMax = Vector2.one; fieldRect.offsetMin = new Vector2(10f, 8f); fieldRect.offsetMax = new Vector2(-(SendButtonWidth + 16f), -8f); sendButton = ChatUiBuilder.NewButton("Send", row, "보내기", 13, ChatUiTheme.AccentButton, ChatUiTheme.PrimaryText); var sendRect = sendButton.GetComponent(); sendRect.anchorMin = new Vector2(1f, 0f); sendRect.anchorMax = new Vector2(1f, 1f); sendRect.pivot = new Vector2(1f, 0.5f); sendRect.sizeDelta = new Vector2(SendButtonWidth, -16f); sendRect.anchoredPosition = new Vector2(-10f, 0f); sendLabel = sendButton.GetComponentInChildren(); sendButton.onClick.AddListener(OnSendButtonClicked); } /// 같은 버튼이 평소엔 "보내기", 응답을 기다리는 동안엔 "취소"다. void OnSendButtonClicked() { if (responding) CancelRequested?.Invoke(); else SubmitCurrent(inputField.text); } // ------------------------------------------------------------------ 설정 public void OpenSettings(ChatConfig config) { if (inputField != null) inputField.DeactivateInputField(); Settings.Open(config); } public void CloseSettings() { Settings.Close(); FocusInput(); } void SubmitCurrent(string value) { if (inputField == null) return; if (string.IsNullOrWhiteSpace(value)) { FocusInput(); return; } inputField.text = string.Empty; Submitted?.Invoke(value.Trim()); FocusInput(); } }