채팅 추가
This commit is contained in:
377
Assets/02_Scripts/Chat/ChatController.cs
Normal file
377
Assets/02_Scripts/Chat/ChatController.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// <summary>
|
||||
/// 채팅 기능의 배선판. 캐릭터 클릭으로 창을 토글하고, 창이 열려 있는 동안
|
||||
/// 창 레이어(클릭 통과 / 포커스 / Esc)를 채팅에 맞게 바꿔준다.
|
||||
///
|
||||
/// 여기서만 다루는 두 가지 까다로운 문제:
|
||||
///
|
||||
/// 1. 클릭 통과 — 기본 상태에서 창은 캐릭터 위에서만 클릭을 받는다. 그대로면
|
||||
/// 채팅창을 눌러도 클릭이 뒤 창으로 새어나간다. 채팅창 사각형을 히트테스트에
|
||||
/// 등록해 그 위에서도 클릭을 받게 한다.
|
||||
///
|
||||
/// 2. 포커스 — 창에 WS_EX_NOACTIVATE 가 걸려 있어 포커스를 받지 않는다.
|
||||
/// 마우스는 포커스 없이도 오지만 키보드는 오지 않는다. 채팅창이 열려 있는
|
||||
/// 동안만 NOACTIVATE 를 내리고 창을 활성화한다. 닫을 때 원래 쓰던 창으로
|
||||
/// 포커스를 돌려준다 — 비서가 작업을 방해하면 안 된다.
|
||||
/// </summary>
|
||||
public class ChatController : MonoBehaviour
|
||||
{
|
||||
[Header("참조 (비우면 씬에서 탐색)")]
|
||||
[SerializeField] ChatWindowUI ui;
|
||||
[SerializeField] AnthropicChatBackend anthropicBackend;
|
||||
[SerializeField] GeminiChatBackend geminiBackend;
|
||||
[SerializeField] ClickThroughHitTest hitTest;
|
||||
[SerializeField] CharacterDragger dragger;
|
||||
[SerializeField] TransparentWindow window;
|
||||
[SerializeField] VrmCharacterLoader loader;
|
||||
[SerializeField] Camera viewCamera;
|
||||
|
||||
[Header("배치")]
|
||||
[Tooltip("캐릭터를 따라다닌다. 끄면 처음 연 자리에 머문다")]
|
||||
[SerializeField] bool followCharacter = true;
|
||||
|
||||
[Tooltip("캐릭터와 채팅창 사이 간격(픽셀)")]
|
||||
[SerializeField] float gapFromCharacter = 16f;
|
||||
|
||||
ChatSession session;
|
||||
ChatConfig config;
|
||||
|
||||
// 키가 없어 설정 화면을 자동으로 띄운 뒤에는 잔소리를 반복하지 않는다.
|
||||
bool announcedMissingKey;
|
||||
|
||||
// 캐릭터의 화면상 크기. 깊이가 고정이라 위치가 바뀌어도 변하지 않으므로 캐시한다.
|
||||
Vector2 characterOffMin, characterOffMax;
|
||||
bool characterMeasured;
|
||||
|
||||
Func<Vector2, bool> hitRegion;
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
IntPtr previousForeground = IntPtr.Zero;
|
||||
#endif
|
||||
|
||||
public bool IsOpen => ui != null && ui.IsOpen;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (viewCamera == null) viewCamera = Camera.main;
|
||||
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
||||
if (hitTest == null) hitTest = FindFirstObjectByType<ClickThroughHitTest>();
|
||||
if (dragger == null) dragger = FindFirstObjectByType<CharacterDragger>();
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
|
||||
if (ui == null) ui = GetComponentInChildren<ChatWindowUI>(true);
|
||||
if (ui == null) ui = gameObject.AddComponent<ChatWindowUI>();
|
||||
|
||||
if (anthropicBackend == null) anthropicBackend = GetComponent<AnthropicChatBackend>();
|
||||
if (anthropicBackend == null) anthropicBackend = gameObject.AddComponent<AnthropicChatBackend>();
|
||||
|
||||
if (geminiBackend == null) geminiBackend = GetComponent<GeminiChatBackend>();
|
||||
if (geminiBackend == null) geminiBackend = gameObject.AddComponent<GeminiChatBackend>();
|
||||
|
||||
config = ChatConfig.Load();
|
||||
ConfigureBackends();
|
||||
session = new ChatSession(ActiveBackend, config.maxHistoryMessages);
|
||||
}
|
||||
|
||||
/// <summary>지금 설정이 가리키는 백엔드.</summary>
|
||||
IChatBackend ActiveBackend => config.IsGemini ? (IChatBackend)geminiBackend : anthropicBackend;
|
||||
|
||||
/// <summary>
|
||||
/// 두 백엔드 모두에 설정을 넣는다.
|
||||
///
|
||||
/// 쓰지 않는 쪽까지 넣어두는 이유: 설정 화면의 연결 테스트는 저장 전 값으로
|
||||
/// 지금 안 쓰는 제공자를 시험할 수도 있고, 저장 직후 제공자가 바뀌면
|
||||
/// 그 백엔드가 곧바로 요청을 받게 된다.
|
||||
/// </summary>
|
||||
void ConfigureBackends()
|
||||
{
|
||||
anthropicBackend.Configure(config);
|
||||
geminiBackend.Configure(config);
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
ui.Submitted += OnSubmitted;
|
||||
ui.CloseRequested += Close;
|
||||
ui.SettingsRequested += OpenSettings;
|
||||
ui.CancelRequested += OnCancelRequested;
|
||||
|
||||
ui.Settings.SaveRequested += OnSettingsSaved;
|
||||
ui.Settings.TestRequested += OnSettingsTestRequested;
|
||||
|
||||
session.UserMessageAdded += ui.AppendUserMessage;
|
||||
session.ResponseStarted += ui.BeginResponse;
|
||||
session.ResponseDelta += ui.AppendResponseDelta;
|
||||
session.ResponseCompleted += OnResponseCompleted;
|
||||
session.ResponseFailed += ui.ShowError;
|
||||
|
||||
if (dragger != null) dragger.Clicked += Toggle;
|
||||
if (loader != null) loader.Loaded += OnCharacterLoaded;
|
||||
|
||||
if (hitTest != null)
|
||||
{
|
||||
hitRegion = ui.ContainsScreenPoint;
|
||||
hitTest.RegisterInteractiveRegion(hitRegion);
|
||||
}
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
ui.Submitted -= OnSubmitted;
|
||||
ui.CloseRequested -= Close;
|
||||
ui.SettingsRequested -= OpenSettings;
|
||||
ui.CancelRequested -= OnCancelRequested;
|
||||
|
||||
if (ui.Settings != null)
|
||||
{
|
||||
ui.Settings.SaveRequested -= OnSettingsSaved;
|
||||
ui.Settings.TestRequested -= OnSettingsTestRequested;
|
||||
}
|
||||
|
||||
session.UserMessageAdded -= ui.AppendUserMessage;
|
||||
session.ResponseStarted -= ui.BeginResponse;
|
||||
session.ResponseDelta -= ui.AppendResponseDelta;
|
||||
session.ResponseCompleted -= OnResponseCompleted;
|
||||
session.ResponseFailed -= ui.ShowError;
|
||||
|
||||
if (dragger != null) dragger.Clicked -= Toggle;
|
||||
if (loader != null) loader.Loaded -= OnCharacterLoaded;
|
||||
|
||||
if (hitTest != null && hitRegion != null)
|
||||
{
|
||||
hitTest.UnregisterInteractiveRegion(hitRegion);
|
||||
hitRegion = null;
|
||||
}
|
||||
|
||||
// 창 스타일을 되돌려 놓지 않으면 채팅창이 열린 채로 비활성화됐을 때
|
||||
// NOACTIVATE 가 꺼진 상태로 남아 사용자 작업의 포커스를 계속 뺏는다.
|
||||
if (IsOpen) Close();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!IsOpen) return;
|
||||
|
||||
// 이 창은 Esc 를 종료 단축키로 쓴다. 채팅 중에는 Esc 가 "닫기"여야 한다.
|
||||
if (window != null) window.SuppressEscapeQuit();
|
||||
|
||||
var keyboard = Keyboard.current;
|
||||
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)
|
||||
{
|
||||
// 설정이 덮여 있으면 대화로만 돌아간다. 한 번 더 눌러야 창이 닫힌다.
|
||||
if (ui.Settings != null && ui.Settings.IsOpen) ui.CloseSettings();
|
||||
else Close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (followCharacter) PlaceNextToCharacter();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 열고 닫기
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (IsOpen) Close();
|
||||
else Open();
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
if (IsOpen) return;
|
||||
|
||||
MeasureCharacter();
|
||||
PlaceNextToCharacter();
|
||||
|
||||
// 창을 보이기 전에 포커스를 먼저 확보한다. 그래야 입력칸이 활성화되는
|
||||
// 시점에 이미 키보드가 우리 쪽으로 오고 있다.
|
||||
AcquireKeyboardFocus();
|
||||
ui.Open();
|
||||
|
||||
PromptForKeyIfMissing();
|
||||
}
|
||||
|
||||
/// <summary>설정 화면을 연다. 트레이 메뉴 등에서도 부를 수 있게 열어둔다.</summary>
|
||||
public void OpenSettings()
|
||||
{
|
||||
if (!IsOpen) Open();
|
||||
ui.OpenSettings(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 키가 없으면 오류 대신 설정 화면을 띄운다.
|
||||
///
|
||||
/// 처음 쓰는 사람이 가장 막히기 쉬운 지점이다. "키가 없다"는 문장만 보여주고
|
||||
/// 파일을 찾아 열게 하면 대부분 여기서 멈춘다.
|
||||
/// </summary>
|
||||
void PromptForKeyIfMissing()
|
||||
{
|
||||
if (config.HasApiKey) return;
|
||||
|
||||
if (!announcedMissingKey)
|
||||
{
|
||||
announcedMissingKey = true;
|
||||
ui.ShowNotice($"대화하려면 {config.ProviderLabel} API 키가 필요해.\n" +
|
||||
"설정을 열어둘게 — 키를 넣고 저장해줘. 제공자도 여기서 고를 수 있어.");
|
||||
}
|
||||
|
||||
ui.OpenSettings(config);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (!IsOpen) return;
|
||||
|
||||
ui.Close();
|
||||
ReleaseKeyboardFocus();
|
||||
}
|
||||
|
||||
/// <summary>대화를 비운다. 트레이 메뉴 등에서 부를 수 있게 열어둔다.</summary>
|
||||
public void ClearConversation()
|
||||
{
|
||||
session.Clear();
|
||||
ui.ClearMessages();
|
||||
}
|
||||
|
||||
void OnSubmitted(string text)
|
||||
{
|
||||
if (!config.HasApiKey)
|
||||
{
|
||||
PromptForKeyIfMissing();
|
||||
return;
|
||||
}
|
||||
|
||||
session.Send(text);
|
||||
}
|
||||
|
||||
/// <summary>기다리다 지쳐 취소했을 때. 요청을 끊고 입력을 되살린다.</summary>
|
||||
void OnCancelRequested()
|
||||
{
|
||||
session.Cancel();
|
||||
ui.CancelResponse();
|
||||
}
|
||||
|
||||
/// <summary>설정 화면의 저장 버튼. 파일에 쓰고 백엔드에 즉시 반영한다.</summary>
|
||||
void OnSettingsSaved(ChatConfig updated)
|
||||
{
|
||||
config = updated;
|
||||
|
||||
if (!config.Save())
|
||||
{
|
||||
ui.ShowError($"설정을 저장하지 못했어.\n{ChatConfig.ConfigPath} 에 쓸 수 있는지 확인해줘.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 다음 요청부터 바로 새 값이 쓰인다. 앱을 다시 켤 필요 없다.
|
||||
ConfigureBackends();
|
||||
session.Backend = ActiveBackend; // 제공자가 바뀌었으면 여기서 갈아끼워진다
|
||||
session.MaxHistoryMessages = config.maxHistoryMessages;
|
||||
|
||||
ui.CloseSettings();
|
||||
|
||||
if (config.HasApiKey)
|
||||
{
|
||||
announcedMissingKey = false;
|
||||
ui.ShowNotice($"설정을 저장했어. {config.ProviderLabel} 로 대화할게.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ui.ShowNotice($"설정을 저장했어. 다만 {config.ProviderLabel} API 키가 비어 있어.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>연결 테스트는 화면에서 고른 제공자로 보낸다. 저장 여부와 무관하다.</summary>
|
||||
void OnSettingsTestRequested(ChatConfig candidate, Action<bool, string> reply)
|
||||
{
|
||||
IChatBackend target = candidate.IsGemini ? (IChatBackend)geminiBackend : anthropicBackend;
|
||||
target.TestConnection(candidate, reply);
|
||||
}
|
||||
|
||||
void OnResponseCompleted(string _)
|
||||
{
|
||||
ui.EndResponse();
|
||||
}
|
||||
|
||||
void OnCharacterLoaded(ICharacterAvatar avatar)
|
||||
{
|
||||
// 모델이 바뀌면 화면상 크기가 달라진다. 다음 배치 때 다시 잰다.
|
||||
characterMeasured = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 배치
|
||||
|
||||
Transform CharacterRoot =>
|
||||
loader != null && loader.Current != null ? loader.Current.Root : null;
|
||||
|
||||
void MeasureCharacter()
|
||||
{
|
||||
if (characterMeasured) return;
|
||||
|
||||
var root = CharacterRoot;
|
||||
if (root == null || viewCamera == null) return;
|
||||
|
||||
// 렌더러를 전부 훑는 계산이라 매 프레임 할 일이 아니다. 캐릭터는 고정 깊이
|
||||
// 평면 위에서만 움직이므로 화면상 크기는 한 번 재면 계속 유효하다.
|
||||
characterMeasured = CharacterScreenBounds.TryMeasure(
|
||||
viewCamera, root, out characterOffMin, out characterOffMax);
|
||||
}
|
||||
|
||||
/// <summary>캐릭터 옆에 창을 붙인다. 오른쪽에 자리가 없으면 왼쪽으로 넘긴다.</summary>
|
||||
void PlaceNextToCharacter()
|
||||
{
|
||||
MeasureCharacter();
|
||||
|
||||
var root = CharacterRoot;
|
||||
if (root == null || viewCamera == null || !characterMeasured) return;
|
||||
|
||||
Vector2 origin = viewCamera.WorldToScreenPoint(root.position);
|
||||
float left = origin.x + characterOffMin.x;
|
||||
float right = origin.x + characterOffMax.x;
|
||||
float top = origin.y + characterOffMax.y;
|
||||
|
||||
Vector2 size = ui.PanelPixelSize;
|
||||
|
||||
float x = right + gapFromCharacter;
|
||||
if (x + size.x > Screen.width) x = left - gapFromCharacter - size.x;
|
||||
|
||||
// 창 위쪽을 캐릭터 머리 높이에 맞춘다. 말풍선처럼 보이는 위치다.
|
||||
float y = top - size.y;
|
||||
|
||||
ui.SetPanelScreenPosition(new Vector2(x, y));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 포커스
|
||||
|
||||
void AcquireKeyboardFocus()
|
||||
{
|
||||
if (hitTest != null) hitTest.SetNoActivate(false);
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
previousForeground = Win32.GetForegroundWindow();
|
||||
if (window != null && window.Hwnd != IntPtr.Zero)
|
||||
{
|
||||
Win32.ForceForeground(window.Hwnd);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void ReleaseKeyboardFocus()
|
||||
{
|
||||
if (hitTest != null) hitTest.SetNoActivate(true);
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
// 쓰던 창으로 포커스를 돌려준다. 이걸 빼먹으면 채팅을 닫은 뒤
|
||||
// 타이핑하던 앱에 글자가 안 들어가서 사용자가 한 번 더 클릭해야 한다.
|
||||
IntPtr self = window != null ? window.Hwnd : IntPtr.Zero;
|
||||
if (previousForeground != IntPtr.Zero &&
|
||||
previousForeground != self &&
|
||||
Win32.IsWindow(previousForeground))
|
||||
{
|
||||
Win32.ForceForeground(previousForeground);
|
||||
}
|
||||
previousForeground = IntPtr.Zero;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user