Files
MyCharacterAgent/Assets/02_Scripts/Chat/ChatController.cs
2026-09-01 14:40:02 +09:00

378 lines
13 KiB
C#

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
}
}