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