using System; using UnityEngine; using UnityEngine.InputSystem; /// /// 채팅 기능의 배선판. 캐릭터 클릭으로 창을 토글하고, 창이 열려 있는 동안 /// 창 레이어(클릭 통과 / 포커스 / Esc)를 채팅에 맞게 바꿔준다. /// /// 여기서만 다루는 두 가지 까다로운 문제: /// /// 1. 클릭 통과 — 기본 상태에서 창은 캐릭터 위에서만 클릭을 받는다. 그대로면 /// 채팅창을 눌러도 클릭이 뒤 창으로 새어나간다. 채팅창 사각형을 히트테스트에 /// 등록해 그 위에서도 클릭을 받게 한다. /// /// 2. 포커스 — 창에 WS_EX_NOACTIVATE 가 걸려 있어 포커스를 받지 않는다. /// 마우스는 포커스 없이도 오지만 키보드는 오지 않는다. 채팅창이 열려 있는 /// 동안만 NOACTIVATE 를 내리고 창을 활성화한다. 닫을 때 원래 쓰던 창으로 /// 포커스를 돌려준다 — 비서가 작업을 방해하면 안 된다. /// 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 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(); if (hitTest == null) hitTest = FindFirstObjectByType(); if (dragger == null) dragger = FindFirstObjectByType(); if (loader == null) loader = FindFirstObjectByType(); if (ui == null) ui = GetComponentInChildren(true); if (ui == null) ui = gameObject.AddComponent(); if (anthropicBackend == null) anthropicBackend = GetComponent(); if (anthropicBackend == null) anthropicBackend = gameObject.AddComponent(); if (geminiBackend == null) geminiBackend = GetComponent(); if (geminiBackend == null) geminiBackend = gameObject.AddComponent(); config = ChatConfig.Load(); ConfigureBackends(); session = new ChatSession(ActiveBackend, config.maxHistoryMessages); } /// 지금 설정이 가리키는 백엔드. IChatBackend ActiveBackend => config.IsGemini ? (IChatBackend)geminiBackend : anthropicBackend; /// /// 두 백엔드 모두에 설정을 넣는다. /// /// 쓰지 않는 쪽까지 넣어두는 이유: 설정 화면의 연결 테스트는 저장 전 값으로 /// 지금 안 쓰는 제공자를 시험할 수도 있고, 저장 직후 제공자가 바뀌면 /// 그 백엔드가 곧바로 요청을 받게 된다. /// 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(); } /// 설정 화면을 연다. 트레이 메뉴 등에서도 부를 수 있게 열어둔다. public void OpenSettings() { if (!IsOpen) Open(); ui.OpenSettings(config); } /// /// 키가 없으면 오류 대신 설정 화면을 띄운다. /// /// 처음 쓰는 사람이 가장 막히기 쉬운 지점이다. "키가 없다"는 문장만 보여주고 /// 파일을 찾아 열게 하면 대부분 여기서 멈춘다. /// 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(); } /// 대화를 비운다. 트레이 메뉴 등에서 부를 수 있게 열어둔다. public void ClearConversation() { session.Clear(); ui.ClearMessages(); } void OnSubmitted(string text) { if (!config.HasApiKey) { PromptForKeyIfMissing(); return; } session.Send(text); } /// 기다리다 지쳐 취소했을 때. 요청을 끊고 입력을 되살린다. void OnCancelRequested() { session.Cancel(); ui.CancelResponse(); } /// 설정 화면의 저장 버튼. 파일에 쓰고 백엔드에 즉시 반영한다. 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 키가 비어 있어."); } } /// 연결 테스트는 화면에서 고른 제공자로 보낸다. 저장 여부와 무관하다. void OnSettingsTestRequested(ChatConfig candidate, Action 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); } /// 캐릭터 옆에 창을 붙인다. 오른쪽에 자리가 없으면 왼쪽으로 넘긴다. 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 } }