채팅 추가

This commit is contained in:
2026-09-01 14:40:02 +09:00
parent cab0d772b1
commit a5a1f1d9e9
50 changed files with 3843 additions and 1124 deletions

View File

@@ -0,0 +1,560 @@
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()
{
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;
}
// ------------------------------------------------------------------ 계층 만들기
/// <summary>
/// 씬에 EventSystem 이 없으면 만든다.
///
/// Active Input Handling 이 신규 Input System 전용이므로 모듈은
/// InputSystemUIInputModule 이어야 한다. StandaloneInputModule 은 레거시
/// UnityEngine.Input 을 쓰기 때문에 이 설정에서 예외를 던진다.
/// </summary>
static void EnsureEventSystem()
{
var existing = FindFirstObjectByType<EventSystem>();
if (existing == null)
{
var go = new GameObject("EventSystem");
existing = go.AddComponent<EventSystem>();
var module = go.AddComponent<InputSystemUIInputModule>();
// 런타임에 붙이면 액션 에셋이 비어 있어 아무 입력도 받지 못한다.
if (module.actionsAsset == null) module.AssignDefaultActions();
}
// InputField 가 IME 상태를 물을 때 레거시 Input 으로 새지 않게 막는다.
// BaseInputModule 은 BaseInput 을 "상속한" 컴포넌트를 기본값으로 고르지 않으므로
// inputOverride 에 직접 넣어야 한다.
var baseModule = existing.GetComponent<BaseInputModule>();
if (baseModule != null && baseModule.inputOverride == null)
{
var safeInput = existing.GetComponent<SafeBaseInput>();
if (safeInput == null) safeInput = existing.gameObject.AddComponent<SafeBaseInput>();
baseModule.inputOverride = safeInput;
}
}
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();
}
}