Files
MyCharacterAgent/Assets/02_Scripts/Chat/ChatWindowUI.cs
2026-09-01 21:36:23 +09:00

530 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.UI;
using UnityEngine.UI;
/// <summary>
/// 채팅창의 겉모습. uGUI 계층을 런타임에 전부 코드로 만든다.
///
/// 씬이나 프리팹에 UI 를 넣지 않는 이유:
/// - 이 프로젝트는 캐릭터도 런타임에 로드한다. UI 만 씬에 박아두면 결이 어긋난다.
/// - .unity 파일은 병합이 사실상 불가능해서 손대는 비용이 크다.
/// - TextMeshPro Essentials 가 임포트돼 있지 않아 TMP 프리팹은 글자가 안 나온다.
///
/// 이 클래스는 대화 내용이 어디서 오는지 모른다. Submitted 로 알리고,
/// AppendUser / BeginResponse / AppendDelta 로 지시받기만 한다.
/// </summary>
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;
/// <summary>설정 겹판. 채팅 패널 안에 얹혀 있어 히트테스트와 배치를 공유한다.</summary>
public ChatSettingsUI Settings { get; private set; }
/// <summary>사용자가 Enter 를 누르거나 보내기를 눌렀을 때.</summary>
public event Action<string> Submitted;
/// <summary>닫기 버튼을 눌렀을 때.</summary>
public event Action CloseRequested;
/// <summary>머리말의 톱니를 눌렀을 때. 설정 내용을 아는 쪽이 응답한다.</summary>
public event Action SettingsRequested;
/// <summary>응답을 기다리는 중에 보내기 버튼(이때는 "취소")을 눌렀을 때.</summary>
public event Action CancelRequested;
public bool IsOpen => panel != null && panel.gameObject.activeSelf;
/// <summary>패널의 화면상 크기(픽셀). 배치 계산에 쓴다.</summary>
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);
}
/// <summary>입력칸에 커서를 둔다. 창을 연 직후와 응답이 끝난 뒤에 부른다.</summary>
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();
}
/// <summary>OS 커서가 채팅창 위에 있는지. 클릭 통과 히트테스트가 이걸 묻는다.</summary>
public bool ContainsScreenPoint(Vector2 screenPoint)
{
if (!IsOpen || panel == null) return false;
// 오버레이 캔버스는 카메라가 없으므로 null 을 넘긴다.
return RectTransformUtility.RectangleContainsScreenPoint(panel, screenPoint, null);
}
/// <summary>패널 왼쪽 아래 모서리를 화면 좌표에 놓는다. 화면 밖으로 나가지 않게 제한한다.</summary>
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();
}
/// <summary>응답용 빈 말풍선을 만든다. 델타가 여기에 쌓인다.</summary>
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();
}
/// <summary>
/// 답이 오기 전까지 점을 굴린다.
///
/// 이게 없으면 멈춘 것과 구분이 안 된다. 특히 사고(thinking)를 켠 모델은
/// 생각하는 동안 델타를 보내긴 하지만 그 안의 글자가 비어 있어서, 화면에는
/// 몇 초에서 몇십 초 동안 아무 변화가 없다.
/// </summary>
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();
}
/// <summary>실패를 말풍선으로 보여준다. 진행 중이던 응답 풍선이 있으면 그걸 바꿔 쓴다.</summary>
public void ShowError(string message)
{
if (streamingBubble != null)
{
streamingBubble.text = message;
var image = streamingBubble.transform.parent.GetComponent<Image>();
if (image != null) image.color = ChatUiTheme.ErrorBubble;
responseHasText = true; // 남아 있는 애니메이션 코루틴을 멈춘다
streamingBubble = null;
}
else
{
AddBubble(message, ChatUiTheme.ErrorBubble, TextAnchor.UpperLeft);
}
SetResponding(false);
ScrollToBottom();
FocusInput();
}
/// <summary>안내용 회색 말풍선. 오류가 아니라 알려주기만 할 때 쓴다.</summary>
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;
}
/// <summary>
/// 응답을 기다리는 동안의 화면 상태.
///
/// 보내기 버튼을 비활성으로 두지 않고 "취소"로 바꾼다. 응답이 늦거나 끊겼을 때
/// 버튼이 전부 죽어 있으면 사용자가 창을 닫는 것 말고 할 수 있는 게 없다.
/// </summary>
void SetResponding(bool value)
{
responding = value;
if (inputField != null) inputField.interactable = !value;
if (sendLabel != null) sendLabel.text = value ? "취소" : "보내기";
}
/// <summary>사용자가 기다리다 취소했을 때. 말풍선을 정리하고 입력을 되살린다.</summary>
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>();
image.sprite = ChatUiTheme.RoundedSmall;
image.type = Image.Type.Sliced;
image.color = background;
var layout = row.gameObject.AddComponent<VerticalLayoutGroup>();
// 말한 쪽에 따라 안쪽 여백을 다르게 줘서 시선이 좌우로 갈리게 한다.
// 풍선 폭 자체를 글자에 맞추려면 계층이 한 겹 더 필요한데, 좁은 창에서는
// 색과 정렬만으로도 충분히 구분된다.
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;
}
/// <summary>
/// 맨 아래로 스크롤을 예약한다. 델타가 초당 수십 번 오므로 이미 예약돼 있으면
/// 다시 걸지 않는다. 한 번의 코루틴이 그때까지 쌓인 내용을 모두 반영한다.
/// </summary>
void ScrollToBottom()
{
if (!isActiveAndEnabled || scrollPending) return;
scrollPending = true;
StartCoroutine(ScrollToBottomNextFrame());
}
/// <summary>
/// 레이아웃이 확정된 뒤에 맨 아래로 내린다.
/// 말풍선 높이는 레이아웃 계산이 이번 프레임 끝에 돌기 때문에, 같은 프레임에서
/// 스크롤 위치를 만지면 이전 높이 기준으로 계산돼 어긋난다.
/// </summary>
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>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 200; // 디버그 HUD(IMGUI)보다 아래지만 3D 캐릭터보다는 위
var scaler = canvasGo.GetComponent<CanvasScaler>();
// 이 프로젝트는 캐릭터 위치, 창 발판, 화면 제한을 전부 물리 픽셀로 계산한다.
// 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<Image>();
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<RectTransform>();
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<ScrollRect>();
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<RectMask2D>();
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<ContentSizeFitter>();
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<RectTransform>();
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<RectTransform>();
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<Text>();
sendButton.onClick.AddListener(OnSendButtonClicked);
}
/// <summary>같은 버튼이 평소엔 "보내기", 응답을 기다리는 동안엔 "취소"다.</summary>
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();
}
}