채팅 추가
This commit is contained in:
560
Assets/02_Scripts/Chat/ChatWindowUI.cs
Normal file
560
Assets/02_Scripts/Chat/ChatWindowUI.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user