530 lines
20 KiB
C#
530 lines
20 KiB
C#
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();
|
||
}
|
||
}
|