diff --git a/.gitignore b/.gitignore index aa16a98..68a534f 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,5 @@ ProjectSettings/Packages/com.unity.learn.iet-framework/Settings.json ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json *.log + +/[Mm]odels/ diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity index 9b5bf0f..5f92178 100644 --- a/Assets/01_Scenes/MainScene.unity +++ b/Assets/01_Scenes/MainScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85bc27b8e51f498881808e664903fbeaefb6c280a83c50129d70e327e9960edd -size 17952 +oid sha256:ad0ec5eb1b9d5b5a0f1ac95c698ab0f0f959a8b0ab80200db26a6ec5eaf3aae3 +size 21109 diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs b/Assets/02_Scripts/Character/CharacterDragger.cs index 8376810..4454014 100644 --- a/Assets/02_Scripts/Character/CharacterDragger.cs +++ b/Assets/02_Scripts/Character/CharacterDragger.cs @@ -45,6 +45,7 @@ public class CharacterDragger : MonoBehaviour Transform target; bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음) bool dragging; // 임계값을 넘겨 실제 드래그 중 + bool prevButtonDown; // 눌린 순간만 잡아내기 위한 직전 프레임 상태 Vector2 pressStartPos; Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표) Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위 @@ -56,6 +57,14 @@ public class CharacterDragger : MonoBehaviour /// 드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다. public event System.Action DragEnded; + /// + /// 캐릭터를 끌지 않고 그냥 눌렀다 뗀 순간 호출. 채팅창 토글이 이걸 쓴다. + /// + /// 클릭과 드래그의 구분은 이미 dragThresholdPixels 로 하고 있으므로 판정을 + /// 새로 만들지 않고 여기에 얹는다. 조금이라도 끌었으면 클릭이 아니다. + /// + public event System.Action Clicked; + void Awake() { hitTest = GetComponent(); @@ -74,7 +83,13 @@ void Update() bool buttonDown = mouse != null && mouse.leftButton.isPressed; #endif - if (!pressing && buttonDown && hitTest.IsOverCharacter) + // 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우 + // (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다. + // 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다. + bool justPressed = buttonDown && !prevButtonDown; + prevButtonDown = buttonDown; + + if (!pressing && justPressed && hitTest.IsOverCharacter) { BeginPress(); } @@ -152,6 +167,7 @@ void EndPress() target = null; if (wasDragging && dragged != null) DragEnded?.Invoke(dragged); + else if (!wasDragging && dragged != null) Clicked?.Invoke(); } bool TryGetCursor(out Vector2 screenPos) diff --git a/Assets/02_Scripts/Chat.meta b/Assets/02_Scripts/Chat.meta new file mode 100644 index 0000000..aa542c5 --- /dev/null +++ b/Assets/02_Scripts/Chat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: af53affe611c89e4cb0292ed9052ee44 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/Chat/AnthropicChatBackend.cs b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs new file mode 100644 index 0000000..7e94172 --- /dev/null +++ b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs @@ -0,0 +1,543 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +/// +/// Anthropic Messages API 를 UnityWebRequest 로 직접 친다. SSE 스트리밍. +/// +/// 왜 공식 C# SDK 를 쓰지 않는가: +/// SDK 는 netstandard2.0 타겟이 있지만 의존성(System.Text.Json 10.x, +/// Microsoft.Extensions.AI.Abstractions)이 Unity Mono/IL2CPP 에서 AOT 문제를 낸다. +/// 그게 원래 .NET 8 사이드카를 두기로 한 이유였다. 하지만 문제는 SDK 의존성이지 +/// HTTP 자체가 아니다. 순수 HTTP + 최소 JSON 은 Unity 에서 그냥 된다. +/// ASR/TTS(sherpa-onnx)를 붙일 때가 되면 그때 사이드카로 옮기고 +/// IChatBackend 만 다시 구현하면 된다. +/// +/// JSON 은 JsonUtility 로 처리한다. 요청은 모양이 고정이라 문제없고, +/// 응답은 스트리밍 델타에서 text 하나만 뽑으면 되므로 모르는 필드는 무시된다. +/// +public class AnthropicChatBackend : MonoBehaviour, IChatBackend +{ + const string Endpoint = "https://api.anthropic.com/v1/messages"; + const string ModelsEndpoint = "https://api.anthropic.com/v1/models"; + const string ApiVersion = "2023-06-01"; + + /// + /// 설정 화면에 보여줄 모델 선택지. 표시 이름과 실제 ID 쌍. + /// + /// 데스크톱 비서는 상시 떠 있으면서 잡담을 주고받으므로 비용과 지연이 그대로 체감된다. + /// 그래서 성능순이 아니라 "일단 이거" 순으로 놓는다. + /// + public static readonly (string Label, string Id)[] SelectableModels = + { + ("Opus 5", "claude-opus-5"), + ("Sonnet 5", "claude-sonnet-5"), + ("Haiku 4.5", "claude-haiku-4-5"), + }; + + /// 응답 깊이 선택지. 값이 클수록 더 생각하고, 더 느리고, 더 비싸다. + public static readonly (string Label, string Id)[] SelectableEfforts = + { + ("빠르게", "low"), + ("보통", "medium"), + ("깊게", "high"), + }; + + [Tooltip("응답이 아예 오지 않을 때 포기하는 시간(초). 0 이면 무제한")] + [SerializeField] int timeoutSeconds = 60; + + [Tooltip("요청/응답 진행 상황을 콘솔에 남긴다. 응답이 안 올 때 어디서 막혔는지 보려면 켠다")] + [SerializeField] bool verboseLog = true; + + ChatConfig config; + UnityWebRequest inflight; + Coroutine routine; + + public bool IsBusy => inflight != null; + + /// 설정을 주입한다. ChatController 가 시작할 때 한 번 부른다. + public void Configure(ChatConfig chatConfig) + { + config = chatConfig; + } + + public void Send(IReadOnlyList history, + Action onDelta, + Action onComplete, + Action onError) + { + if (IsBusy) + { + onError?.Invoke("아직 이전 응답을 받는 중이야."); + return; + } + + if (config == null) config = ChatConfig.Load(); + + string apiKey = config.ResolveAnthropicKey(); + if (string.IsNullOrEmpty(apiKey)) + { + onError?.Invoke("Claude API 키가 없어. 설정에서 넣어줘."); + return; + } + + routine = StartCoroutine(SendRoutine(apiKey, history, onDelta, onComplete, onError)); + } + + /// + /// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 부른다. + /// + /// 메시지를 보내보는 대신 Models API 를 조회한다. 토큰을 한 개도 쓰지 않으면서 + /// 키가 유효한지(401)와 모델 이름이 맞는지(404)를 한 번에 가른다. + /// 대화 요청과 별개의 요청 객체를 쓰므로 응답을 받는 중에도 눌러볼 수 있다. + /// + public void TestConnection(ChatConfig configToTest, Action onResult) + { + if (configToTest == null) + { + onResult?.Invoke(false, "설정이 비어 있어요."); + return; + } + + string apiKey = configToTest.ResolveAnthropicKey(); + if (string.IsNullOrEmpty(apiKey)) + { + onResult?.Invoke(false, "API 키를 먼저 입력해주세요."); + return; + } + + StartCoroutine(TestRoutine(apiKey, configToTest.model, onResult)); + } + + IEnumerator TestRoutine(string apiKey, string model, Action onResult) + { + string url = ModelsEndpoint + "/" + UnityWebRequest.EscapeURL(model); + + using (var request = UnityWebRequest.Get(url)) + { + request.SetRequestHeader("anthropic-version", ApiVersion); + request.SetRequestHeader("x-api-key", apiKey); + request.timeout = 20; + + yield return request.SendWebRequest(); + + if (request.result == UnityWebRequest.Result.ConnectionError) + { + onResult?.Invoke(false, "네트워크에 연결할 수 없어요."); + yield break; + } + + switch (request.responseCode) + { + case 200: + onResult?.Invoke(true, $"연결 성공 — {model}"); + break; + case 401: + case 403: + onResult?.Invoke(false, "API 키가 올바르지 않아요."); + break; + case 404: + onResult?.Invoke(false, $"'{model}' 모델을 찾을 수 없어요."); + break; + case 429: + // 키 자체는 유효하다. 한도만 찬 상태. + onResult?.Invoke(true, "키는 정상인데 요청 한도에 걸려 있어요."); + break; + default: + onResult?.Invoke(false, $"확인 실패 (오류 {request.responseCode})"); + break; + } + } + } + + public void Cancel() + { + if (routine != null) + { + StopCoroutine(routine); + routine = null; + } + + // Abort 는 다음 프레임에 완료되므로 참조를 먼저 끊어 IsBusy 를 즉시 내린다. + var request = inflight; + inflight = null; + if (request != null) + { + try { request.Abort(); } catch (Exception) { } + request.Dispose(); + } + } + + void OnDestroy() + { + Cancel(); + } + + IEnumerator SendRoutine(string apiKey, + IReadOnlyList history, + Action onDelta, + Action onComplete, + Action onError) + { + string body = BuildRequestJson(history); + + var request = new UnityWebRequest(Endpoint, UnityWebRequest.kHttpVerbPOST); + var handler = new SseDownloadHandler(); + request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)); + request.downloadHandler = handler; + request.SetRequestHeader("content-type", "application/json"); + request.SetRequestHeader("accept", "text/event-stream"); + request.SetRequestHeader("anthropic-version", ApiVersion); + request.SetRequestHeader("x-api-key", apiKey); + if (timeoutSeconds > 0) request.timeout = timeoutSeconds; + + // 핸들러가 본문을 SSE 로 팔지 오류로 모아둘지는 상태 코드를 봐야 정해진다. + handler.Bind(request, onDelta); + + inflight = request; + + float startedAt = Time.realtimeSinceStartup; + if (verboseLog) + { + Debug.Log($"[Anthropic] 요청 전송: model={config.model} effort={config.effort} " + + $"메시지 {history.Count}개 본문 {body.Length}자"); + } + + yield return request.SendWebRequest(); + + routine = null; + + if (verboseLog) + { + float elapsed = Time.realtimeSinceStartup - startedAt; + Debug.Log($"[Anthropic] 응답 종료: HTTP {request.responseCode} result={request.result} " + + $"{elapsed:F1}초 / 받은 글자 {handler.Text.Length} / " + + $"첫 글자까지 {(handler.FirstTextAt > 0f ? (handler.FirstTextAt - startedAt).ToString("F1") + "초" : "없음")} / " + + $"사고 블록 {(handler.SawThinking ? "있음" : "없음")}"); + } + + // Cancel() 이 먼저 참조를 끊었다면 이 응답은 버려진 것이다. + if (inflight != request) + { + request.Dispose(); + yield break; + } + inflight = null; + + bool failed = request.result != UnityWebRequest.Result.Success || request.responseCode >= 400; + string failure = failed ? DescribeFailure(request, handler) : null; + string full = failed ? null : handler.Text; + + request.Dispose(); + + if (failed) + { + onError?.Invoke(failure); + yield break; + } + + if (string.IsNullOrWhiteSpace(full)) + { + onError?.Invoke("응답이 비어 있어. 잠시 뒤 다시 시도해줘."); + yield break; + } + + onComplete?.Invoke(full); + } + + /// 실패를 사용자에게 그대로 보여줄 수 있는 한 문장으로 만든다. + static string DescribeFailure(UnityWebRequest request, SseDownloadHandler handler) + { + // 본문에 구조화된 오류가 있으면 그게 가장 정확하다. + string apiMessage = handler.ErrorMessage; + if (!string.IsNullOrEmpty(apiMessage)) + { + if (request.responseCode == 401) return "API 키가 거부됐어: " + apiMessage; + if (request.responseCode == 429) return "요청이 너무 잦아. 잠시 뒤 다시 해줘: " + apiMessage; + return "오류 " + request.responseCode + ": " + apiMessage; + } + + if (request.result == UnityWebRequest.Result.ConnectionError) + return "연결에 실패했어. 네트워크를 확인해줘. (" + request.error + ")"; + + return "오류 " + request.responseCode + ": " + request.error; + } + + string BuildRequestJson(IReadOnlyList history) + { + var messages = new ReqMessage[history.Count]; + for (int i = 0; i < history.Count; i++) + { + messages[i] = new ReqMessage + { + role = history[i].RoleName, + content = history[i].Text, + }; + } + + var request = new Request + { + model = config.model, + max_tokens = Mathf.Max(64, config.maxTokens), + stream = true, + // 페르소나는 매 요청 동일한 접두사라 캐시 대상이다. 다만 캐시는 + // 1024 토큰 이상부터 걸리므로 짧은 페르소나에서는 그냥 무시된다. + system = new[] + { + new ReqSystemBlock + { + type = "text", + text = config.persona, + cache_control = new ReqCacheControl { type = "ephemeral" }, + }, + }, + messages = messages, + // 사고는 켜두되 깊이를 낮춘다. 완전히 끄면 Opus 5 에서 사고 흔적이 + // 본문에 새는 사례가 있고, effort 를 낮추는 쪽이 비용도 지연도 낫다. + thinking = new ReqThinking { type = "adaptive" }, + output_config = new ReqOutputConfig { effort = config.effort }, + }; + + return JsonUtility.ToJson(request); + } + + // --- 요청 본문 --- + // JsonUtility 는 선언한 필드를 빠짐없이 직렬화한다. 모양이 고정이라 그게 문제되지 않는다. + + [Serializable] + class Request + { + public string model; + public int max_tokens; + public bool stream; + public ReqSystemBlock[] system; + public ReqMessage[] messages; + public ReqThinking thinking; + public ReqOutputConfig output_config; + } + + [Serializable] + class ReqSystemBlock + { + public string type; + public string text; + public ReqCacheControl cache_control; + } + + [Serializable] + class ReqCacheControl + { + public string type; + } + + [Serializable] + class ReqMessage + { + public string role; + public string content; + } + + [Serializable] + class ReqThinking + { + public string type; + } + + [Serializable] + class ReqOutputConfig + { + public string effort; + } + + /// + /// SSE 스트림을 줄 단위로 끊어 text_delta 만 뽑아낸다. + /// + /// 기본 DownloadHandlerBuffer 는 응답이 다 와야 내용을 주므로 스트리밍이 되지 않는다. + /// DownloadHandlerScript 는 청크가 도착할 때마다 ReceiveData 를 부른다 + /// (메인 스레드에서 불리므로 Unity 객체를 만져도 안전하다). + /// + /// 청크 경계는 UTF-8 문자 중간에서도 잘리고 SSE 줄 중간에서도 잘린다. + /// 그래서 상태를 가진 Decoder 와 줄 버퍼를 둘 다 유지한다. + /// + class SseDownloadHandler : DownloadHandlerScript + { + readonly Decoder decoder = Encoding.UTF8.GetDecoder(); + readonly StringBuilder lineBuffer = new StringBuilder(); + readonly StringBuilder accumulated = new StringBuilder(); + readonly StringBuilder rawError = new StringBuilder(); + + char[] charBuffer = new char[2048]; + + UnityWebRequest request; + Action onDelta; + + // 버퍼를 미리 잡아두면 청크마다 새 배열이 생기지 않는다. + // 스트리밍은 초당 수십 번 콜백이 오므로 여기서 나오는 쓰레기가 무시할 양이 아니다. + public SseDownloadHandler() : base(new byte[16 * 1024]) { } + + /// 지금까지 이어 붙인 응답 전체. + public string Text => accumulated.ToString(); + + /// API 가 구조화된 오류를 보냈다면 그 message. 없으면 null. + public string ErrorMessage { get; private set; } + + /// + /// 사고 델타를 한 번이라도 봤는지. display 가 기본값(omitted)이면 사고 델타의 + /// 글자가 비어 있어서, 겉보기엔 아무것도 안 오는 것과 구분되지 않는다. + /// 응답이 늦을 때 "멈춘 것"인지 "생각 중"인지 가르는 단서다. + /// + public bool SawThinking { get; private set; } + + /// 첫 글자가 도착한 시각(realtimeSinceStartup). 아직이면 0. + public float FirstTextAt { get; private set; } + + public void Bind(UnityWebRequest owner, Action deltaCallback) + { + request = owner; + onDelta = deltaCallback; + } + + protected override bool ReceiveData(byte[] data, int dataLength) + { + // 여기서 false 를 돌려주면 UnityWebRequest 가 다운로드를 중단한다. + // 빈 청크는 스트림이 끝났다는 뜻이 아니므로 계속 받아야 한다. + if (data == null || dataLength == 0) return true; + + int needed = decoder.GetCharCount(data, 0, dataLength, false); + if (charBuffer.Length < needed) charBuffer = new char[Mathf.NextPowerOfTwo(needed)]; + + int decoded = decoder.GetChars(data, 0, dataLength, charBuffer, 0, false); + + // 오류 응답은 SSE 가 아니라 그냥 JSON 한 덩어리로 온다. 통째로 모았다가 끝에 판다. + if (request != null && request.responseCode >= 400) + { + rawError.Append(charBuffer, 0, decoded); + return true; + } + + for (int i = 0; i < decoded; i++) + { + char c = charBuffer[i]; + if (c == '\n') + { + HandleLine(lineBuffer.ToString()); + lineBuffer.Length = 0; + } + else if (c != '\r') + { + lineBuffer.Append(c); + } + } + + return true; + } + + protected override void CompleteContent() + { + if (lineBuffer.Length > 0) + { + HandleLine(lineBuffer.ToString()); + lineBuffer.Length = 0; + } + + if (rawError.Length > 0 && ErrorMessage == null) + { + ParseError(rawError.ToString()); + } + } + + void HandleLine(string line) + { + // "event: ..." 줄은 볼 필요가 없다. data 의 JSON 안에 같은 type 이 들어 있다. + if (!line.StartsWith("data:", StringComparison.Ordinal)) return; + + string json = line.Substring(5).Trim(); + if (json.Length == 0) return; + + SseEnvelope evt; + try + { + evt = JsonUtility.FromJson(json); + } + catch (Exception) + { + return; // 모르는 모양의 이벤트는 조용히 넘긴다 + } + + if (evt == null) return; + + if (evt.type == "error") + { + ErrorMessage = evt.error != null && !string.IsNullOrEmpty(evt.error.message) + ? evt.error.message + : "알 수 없는 오류"; + return; + } + + if (evt.type != "content_block_delta" || evt.delta == null) return; + + // 사고 델타는 display 가 omitted 면 글자가 비어서 온다. 버리되, 왔다는 + // 사실은 남긴다 — 응답이 늦을 때 멈춘 건지 생각 중인지 가르는 단서다. + if (evt.delta.type == "thinking_delta") + { + SawThinking = true; + return; + } + + if (evt.delta.type == "text_delta" && !string.IsNullOrEmpty(evt.delta.text)) + { + if (FirstTextAt <= 0f) FirstTextAt = Time.realtimeSinceStartup; + accumulated.Append(evt.delta.text); + onDelta?.Invoke(evt.delta.text); + } + } + + void ParseError(string json) + { + try + { + var envelope = JsonUtility.FromJson(json); + if (envelope != null && envelope.error != null && !string.IsNullOrEmpty(envelope.error.message)) + { + ErrorMessage = envelope.error.message; + } + } + catch (Exception) + { + // 본문이 JSON 이 아니면 상태 코드만으로 보고한다. + } + } + } + + // --- 응답 파싱 --- + // 필요한 필드만 선언한다. JsonUtility 는 모르는 필드를 조용히 버린다. + // 값을 넣는 쪽이 JsonUtility 라 컴파일러는 "한 번도 대입되지 않았다"고 본다. +#pragma warning disable 0649 + + [Serializable] + class SseEnvelope + { + public string type; + public SseDelta delta; + public SseError error; + } + + [Serializable] + class SseDelta + { + public string type; + public string text; + } + + [Serializable] + class SseError + { + public string type; + public string message; + } + +#pragma warning restore 0649 +} diff --git a/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta new file mode 100644 index 0000000..1833bae --- /dev/null +++ b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d26819ef7f9d3142b8c3377f1210066 \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatConfig.cs b/Assets/02_Scripts/Chat/ChatConfig.cs new file mode 100644 index 0000000..a8a8dd8 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatConfig.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using UnityEngine; + +/// +/// 채팅 설정. 실행 파일 옆 Config/chat.json 에서 읽는다. +/// +/// API 키를 빌드에 넣지 않는 이유는 모델을 빌드에 넣지 않는 이유와 같다 — +/// 앱은 빈 껍데기로 배포하고 사용자가 자기 것을 넣는다. 키가 코드나 씬에 +/// 직렬화되면 배포본에서 그대로 추출된다. +/// +/// 제공자(Claude / Gemini)별로 키와 모델을 따로 둔다. 하나만 두고 돌려쓰면 +/// 제공자를 바꿀 때마다 키를 다시 붙여넣어야 한다. +/// +/// 키 탐색 순서 +/// 1. Config/chat.json 의 값 — 앱 안 설정 화면에서 입력한 것 +/// 2. 환경 변수 (ANTHROPIC_API_KEY / GEMINI_API_KEY) — 개발 중 편의 +/// +/// 앱에서 입력한 값이 환경 변수를 이긴다. 반대로 하면 설정 화면에서 키를 바꿔도 +/// 아무 일이 안 일어나는 것처럼 보여서 사용자가 원인을 찾을 수 없다. +/// +[Serializable] +public class ChatConfig +{ + public const string ProviderAnthropic = "anthropic"; + public const string ProviderGemini = "gemini"; + + /// 어느 제공자를 쓸지. ProviderAnthropic 또는 ProviderGemini. + public string provider = ProviderAnthropic; + + // --- Anthropic --- + + /// Anthropic API 키. 비우면 환경 변수 ANTHROPIC_API_KEY 를 쓴다. + public string apiKey = ""; + + public string model = "claude-opus-5"; + + /// + /// 사고 깊이. Anthropic 전용이다. 잡담에 깊이 생각할 이유가 없고 지연이 그대로 체감된다. + /// low / medium / high / xhigh / max. + /// + public string effort = "low"; + + // --- Gemini --- + + /// Gemini API 키. 비우면 환경 변수 GEMINI_API_KEY 를 쓴다. + public string geminiApiKey = ""; + + public string geminiModel = "gemini-3.7-flash"; + + // --- 공통 --- + + /// + /// 응답 길이 상한. 데스크톱 비서의 대사는 짧아야 하므로 크게 잡을 이유가 없다. + /// 스트리밍이라 HTTP 타임아웃 걱정은 없지만, 상한이 크면 모델이 길게 쓰는 경향이 있다. + /// + public int maxTokens = 2048; + + /// 대화 기록을 몇 턴까지 들고 갈지. 넘으면 오래된 것부터 버린다. + public int maxHistoryMessages = 40; + + /// 캐릭터 페르소나. 시스템 프롬프트로 들어간다. 제공자와 무관하게 공용이다. + [TextArea(4, 20)] + public string persona = + "너는 사용자의 데스크톱 위에 사는 작은 캐릭터 비서다.\n" + + "말투는 친근한 반말. 사용자가 존댓말을 쓰면 존댓말로 맞춰준다.\n" + + "화면 한켠의 작은 말풍선에 표시되므로 답변은 두세 문장 안에서 끝낸다.\n" + + "목록이나 표는 쓰지 않고, 길어질 것 같으면 핵심만 말한 뒤 더 들을지 되묻는다.\n" + + "모르는 것은 아는 척하지 않는다."; + + const string FileName = "chat.json"; + + /// 실행 파일 옆 Config 폴더. VrmCharacterLoader.ModelsDirectory 와 같은 기준. + public static string ConfigDirectory + { + get + { + string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + return Path.Combine(baseDir, "Config"); + } + } + + public static string ConfigPath => Path.Combine(ConfigDirectory, FileName); + + /// 설정을 읽는다. 파일이 없으면 기본값으로 템플릿을 만들고 그 기본값을 돌려준다. + public static ChatConfig Load() + { + var config = new ChatConfig(); + + try + { + string path = ConfigPath; + if (File.Exists(path)) + { + string json = File.ReadAllText(path); + // 덮어쓰기가 아니라 채워넣기다. 사용자가 일부 항목만 적어도 + // 나머지는 위에 선언한 기본값이 살아남는다. 항목을 새로 추가해도 + // 예전 설정 파일이 그대로 열린다. + JsonUtility.FromJsonOverwrite(json, config); + } + else + { + Directory.CreateDirectory(ConfigDirectory); + File.WriteAllText(path, JsonUtility.ToJson(config, true)); + Debug.Log($"[ChatConfig] 설정 템플릿을 만들었습니다: {path}"); + } + } + catch (Exception e) + { + Debug.LogError($"[ChatConfig] 설정을 읽지 못했습니다. 기본값을 씁니다: {e.Message}"); + } + + config.Normalize(); + return config; + } + + /// 설정을 파일에 쓴다. 설정 화면의 저장 버튼이 부른다. + public bool Save() + { + Normalize(); + + try + { + Directory.CreateDirectory(ConfigDirectory); + File.WriteAllText(ConfigPath, JsonUtility.ToJson(this, true)); + return true; + } + catch (Exception e) + { + Debug.LogError($"[ChatConfig] 설정을 저장하지 못했습니다: {e.Message}"); + return false; + } + } + + /// 손으로 고친 설정 파일에 모르는 값이 적혀 있을 수 있다. 아는 값으로 되돌린다. + void Normalize() + { + if (provider != ProviderGemini) provider = ProviderAnthropic; + } + + // ------------------------------------------------------------------ 제공자 + + public bool IsGemini => provider == ProviderGemini; + + /// 지금 제공자에서 쓸 모델 ID. + public string ActiveModel => IsGemini ? geminiModel : model; + + /// 사람에게 보여줄 제공자 이름. + public string ProviderLabel => IsGemini ? "Gemini" : "Claude"; + + // ------------------------------------------------------------------ 키 + + /// 환경 변수를 읽는다. 없거나 접근이 막혀 있으면 null. + static string ReadEnvironment(string name) + { + try + { + string value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + catch (Exception) + { + // 환경 변수 접근이 막힌 환경도 있다. + return null; + } + } + + public static string EnvironmentKeyFor(string providerId) => + providerId == ProviderGemini + ? ReadEnvironment("GEMINI_API_KEY") + : ReadEnvironment("ANTHROPIC_API_KEY"); + + /// Anthropic 백엔드가 쓸 키. 없으면 null. + public string ResolveAnthropicKey() => + !string.IsNullOrWhiteSpace(apiKey) ? apiKey.Trim() : EnvironmentKeyFor(ProviderAnthropic); + + /// Gemini 백엔드가 쓸 키. 없으면 null. + public string ResolveGeminiKey() => + !string.IsNullOrWhiteSpace(geminiApiKey) ? geminiApiKey.Trim() : EnvironmentKeyFor(ProviderGemini); + + /// 지금 제공자의 키. 없으면 null. + public string ResolveApiKey() => IsGemini ? ResolveGeminiKey() : ResolveAnthropicKey(); + + /// 지금 제공자로 대화를 시작할 수 있는지. + public bool HasApiKey => !string.IsNullOrEmpty(ResolveApiKey()); + + /// 설정 파일에는 키가 없고 환경 변수만 있는 상태인지. 설정 화면에서 안내한다. + public bool UsingEnvironmentKey + { + get + { + string typed = IsGemini ? geminiApiKey : apiKey; + return string.IsNullOrWhiteSpace(typed) && EnvironmentKeyFor(provider) != null; + } + } +} diff --git a/Assets/02_Scripts/Chat/ChatConfig.cs.meta b/Assets/02_Scripts/Chat/ChatConfig.cs.meta new file mode 100644 index 0000000..0b03665 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13145ad34b529d6499646494ce3c9d3f \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatController.cs b/Assets/02_Scripts/Chat/ChatController.cs new file mode 100644 index 0000000..5cf67af --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatController.cs @@ -0,0 +1,377 @@ +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 + } +} diff --git a/Assets/02_Scripts/Chat/ChatController.cs.meta b/Assets/02_Scripts/Chat/ChatController.cs.meta new file mode 100644 index 0000000..4eb13c0 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 368054bb1581f7848a76858b19700f17 \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatSession.cs b/Assets/02_Scripts/Chat/ChatSession.cs new file mode 100644 index 0000000..8500699 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatSession.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// 대화 기록을 들고 백엔드에 요청을 넘긴다. UI 와 백엔드 사이의 유일한 연결점. +/// +/// UI 는 이 클래스의 이벤트만 구독하고 백엔드를 전혀 모른다. 반대로 백엔드는 +/// UI 를 모른다. 나중에 대화 흐름을 사이드카로 옮기거나 음성 입력을 붙일 때 +/// 이 자리가 그대로 접합부가 된다. +/// +/// MonoBehaviour 가 아닌 이유: 씬에 붙일 이유가 없고, 테스트에서 그냥 new 할 수 있다. +/// +public class ChatSession +{ + readonly List history = new List(); + IChatBackend backend; + int maxHistoryMessages; + + /// 사용자 발화가 기록에 들어갔을 때. + public event Action UserMessageAdded; + + /// 응답이 시작될 때. UI 는 여기서 빈 말풍선을 만든다. + public event Action ResponseStarted; + + /// 응답 조각이 도착할 때마다. + public event Action ResponseDelta; + + /// 응답이 끝났을 때. 인자는 전체 응답. + public event Action ResponseCompleted; + + /// 실패했을 때. 인자는 사용자에게 보여줄 문장. + public event Action ResponseFailed; + + public ChatSession(IChatBackend backend, int maxHistoryMessages) + { + this.backend = backend ?? throw new ArgumentNullException(nameof(backend)); + this.maxHistoryMessages = Mathf.Max(2, maxHistoryMessages); + } + + /// + /// 지금 쓰는 백엔드. 설정에서 제공자(Claude / Gemini)를 바꾸면 여기가 교체된다. + /// + /// 세션을 새로 만들지 않고 갈아끼우는 이유는 대화를 잇기 위해서다. 기록은 + /// 역할과 글자뿐이라 제공자가 달라져도 그대로 넘어간다. + /// 교체 순간 진행 중이던 응답은 버린다 — 다른 곳으로 보낸 요청의 답을 + /// 새 제공자의 대화에 이어붙일 수는 없다. + /// + public IChatBackend Backend + { + get => backend; + set + { + if (value == null || ReferenceEquals(value, backend)) return; + Cancel(); + backend = value; + } + } + + /// + /// 들고 갈 대화 길이. 설정에서 바꾸면 여기로 들어온다. + /// 세션을 새로 만들지 않고 갈아끼우는 이유는 지금까지의 대화를 잃지 않기 위해서다. + /// + public int MaxHistoryMessages + { + get => maxHistoryMessages; + set + { + maxHistoryMessages = Mathf.Max(2, value); + TrimHistory(); + } + } + + /// 응답을 기다리는 중인지. + public bool IsBusy => backend.IsBusy; + + public IReadOnlyList History => history; + + /// 사용자 발화를 기록에 넣고 응답을 요청한다. + public void Send(string text) + { + if (string.IsNullOrWhiteSpace(text)) return; + + if (backend.IsBusy) + { + ResponseFailed?.Invoke("아직 대답하는 중이야. 잠깐만."); + return; + } + + text = text.Trim(); + history.Add(new ChatMessage(ChatRole.User, text)); + TrimHistory(); + UserMessageAdded?.Invoke(text); + + ResponseStarted?.Invoke(); + + backend.Send( + history, + delta => ResponseDelta?.Invoke(delta), + full => + { + history.Add(new ChatMessage(ChatRole.Assistant, full)); + TrimHistory(); + ResponseCompleted?.Invoke(full); + }, + reason => + { + // 실패한 턴의 사용자 발화는 기록에서 뺀다. 남겨두면 다음 요청에서 + // user 가 연달아 두 번 나오는 모양이 되고, 재시도할 때 중복된다. + if (history.Count > 0 && history[history.Count - 1].Role == ChatRole.User) + { + history.RemoveAt(history.Count - 1); + } + ResponseFailed?.Invoke(reason); + }); + } + + /// 진행 중인 응답을 중단한다. + public void Cancel() + { + if (!backend.IsBusy) return; + + backend.Cancel(); + if (history.Count > 0 && history[history.Count - 1].Role == ChatRole.User) + { + history.RemoveAt(history.Count - 1); + } + } + + /// 대화를 처음부터 다시 시작한다. + public void Clear() + { + Cancel(); + history.Clear(); + } + + /// + /// 오래된 대화를 버린다. + /// + /// 앞에서부터 버리되 assistant 로 시작하지 않도록 맞춘다. API 는 user 로 시작하는 + /// 기록을 기대하고, assistant 로 시작하면 프리필로 해석돼 최신 모델에서는 거부된다. + /// + void TrimHistory() + { + while (history.Count > maxHistoryMessages) + { + history.RemoveAt(0); + } + + while (history.Count > 0 && history[0].Role == ChatRole.Assistant) + { + history.RemoveAt(0); + } + } +} diff --git a/Assets/02_Scripts/Chat/ChatSession.cs.meta b/Assets/02_Scripts/Chat/ChatSession.cs.meta new file mode 100644 index 0000000..a01c20f --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatSession.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b67fa2c2c2ac11a43bc3896eae41eda1 \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatSettingsUI.cs b/Assets/02_Scripts/Chat/ChatSettingsUI.cs new file mode 100644 index 0000000..6de8973 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatSettingsUI.cs @@ -0,0 +1,529 @@ +using System; +using UnityEngine; +using UnityEngine.UI; + +/// +/// 설정 화면. 채팅 패널 위를 덮는 겹판으로 만든다. +/// +/// 별도 창이 아니라 겹판인 이유: +/// - 클릭 통과 히트테스트에 등록된 사각형이 채팅 패널 하나뿐이라, 같은 사각형 +/// 안에 있으면 히트테스트를 손댈 필요가 없다. +/// - 캐릭터를 따라다니는 배치 계산도 그대로 재사용된다. +/// +/// MonoBehaviour 가 아닌 이유: 씬 수명주기가 필요 없고, 채팅창이 자기 자식으로 +/// 만들어 들고 있으면 충분하다. 네트워크 요청이 필요한 "연결 테스트"는 직접 하지 않고 +/// TestRequested 이벤트로 넘긴다 — UI 는 백엔드를 몰라야 한다. +/// +public class ChatSettingsUI +{ + const float HeaderHeight = 34f; + const float FooterHeight = 48f; + const float RowHeight = 34f; + const float SegmentHeight = 30f; + + static readonly (string Label, string Id)[] Providers = + { + ("Claude", ChatConfig.ProviderAnthropic), + ("Gemini", ChatConfig.ProviderGemini), + }; + + readonly RectTransform root; + readonly Segmented providerSegments; + readonly InputField keyField; + readonly Text keyPlaceholder; + readonly Text keyStatus; + readonly Text testResult; + readonly Button testButton; + readonly Button revealButton; + readonly GameObject anthropicModelRow; + readonly GameObject geminiModelRow; + readonly Segmented anthropicModelSegments; + readonly Segmented geminiModelSegments; + readonly GameObject effortGroup; + readonly Segmented effortSegments; + readonly InputField personaField; + + ChatConfig working; + + /// 지금 입력칸이 어느 제공자의 키를 들고 있는지. 전환할 때 되돌려 넣어야 한다. + string editingProvider = ChatConfig.ProviderAnthropic; + + /// 저장 버튼. 인자는 UI 값이 반영된 설정 객체. + public event Action SaveRequested; + + /// 설정을 닫고 대화로 돌아간다. + public event Action CloseRequested; + + /// 연결 테스트. 두 번째 인자로 결과를 돌려받는다 (성공 여부, 사람이 읽을 문장). + public event Action> TestRequested; + + public bool IsOpen => root != null && root.gameObject.activeSelf; + + public ChatSettingsUI(RectTransform parent) + { + root = ChatUiBuilder.NewRect("Settings", parent); + ChatUiBuilder.Stretch(root); + + // 아래 대화 내용이 비쳐 보이면 읽기 어렵다. 패널 배경을 불투명하게 덮는다. + var background = root.gameObject.AddComponent(); + background.sprite = ChatUiTheme.RoundedPanel; + background.type = Image.Type.Sliced; + background.color = new Color( + ChatUiTheme.PanelBackground.r, ChatUiTheme.PanelBackground.g, + ChatUiTheme.PanelBackground.b, 1f); + + BuildHeader(); + RectTransform content = BuildScrollArea(); + + // --- 제공자 --- + AddSection(content, "AI 제공자"); + var providerRow = ChatUiBuilder.NewRect("ProviderRow", content); + ChatUiBuilder.SetHeight(providerRow, SegmentHeight); + providerSegments = new Segmented(providerRow, Providers, OnProviderChanged); + + // --- API 키 --- + AddSection(content, "API 키"); + + var keyRow = ChatUiBuilder.NewRect("KeyRow", content); + ChatUiBuilder.SetHeight(keyRow, RowHeight); + + keyField = ChatUiBuilder.NewInputField("Key", keyRow, "sk-ant-...", 13); + keyField.contentType = InputField.ContentType.Password; + keyPlaceholder = keyField.placeholder as Text; + var keyRect = keyField.GetComponent(); + keyRect.anchorMin = Vector2.zero; + keyRect.anchorMax = Vector2.one; + keyRect.offsetMin = Vector2.zero; + keyRect.offsetMax = new Vector2(-52f, 0f); + keyField.onValueChanged.AddListener(_ => RefreshKeyStatus()); + + revealButton = ChatUiBuilder.NewButton("Reveal", keyRow, "보기", 12, + ChatUiTheme.InputBackground, ChatUiTheme.DimText); + var revealRect = revealButton.GetComponent(); + revealRect.anchorMin = new Vector2(1f, 0f); + revealRect.anchorMax = Vector2.one; + revealRect.pivot = new Vector2(1f, 0.5f); + revealRect.sizeDelta = new Vector2(46f, 0f); + revealRect.anchoredPosition = Vector2.zero; + revealButton.onClick.AddListener(ToggleReveal); + + keyStatus = ChatUiBuilder.NewText("KeyStatus", content, string.Empty, 11, ChatUiTheme.DimText); + keyStatus.alignment = TextAnchor.UpperLeft; + keyStatus.horizontalOverflow = HorizontalWrapMode.Wrap; + keyStatus.verticalOverflow = VerticalWrapMode.Overflow; + ChatUiBuilder.SetHeight(keyStatus.transform, 48f); + + var testRow = ChatUiBuilder.NewRect("TestRow", content); + ChatUiBuilder.SetHeight(testRow, SegmentHeight); + + testButton = ChatUiBuilder.NewButton("Test", testRow, "연결 테스트", 12, + ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText); + var testRect = testButton.GetComponent(); + testRect.anchorMin = Vector2.zero; + testRect.anchorMax = new Vector2(0f, 1f); + testRect.pivot = new Vector2(0f, 0.5f); + testRect.sizeDelta = new Vector2(88f, 0f); + testRect.anchoredPosition = Vector2.zero; + testButton.onClick.AddListener(RunTest); + + testResult = ChatUiBuilder.NewText("TestResult", testRow, string.Empty, 11, ChatUiTheme.DimText); + testResult.alignment = TextAnchor.MiddleLeft; + testResult.horizontalOverflow = HorizontalWrapMode.Wrap; + var resultRect = testResult.rectTransform; + resultRect.anchorMin = Vector2.zero; + resultRect.anchorMax = Vector2.one; + resultRect.offsetMin = new Vector2(96f, 0f); + resultRect.offsetMax = Vector2.zero; + + // --- 모델 --- + // 제공자마다 목록이 다르므로 두 벌을 만들어 두고 보이는 쪽만 바꾼다. + // 하나를 만들어 놓고 항목을 갈아끼우면 버튼을 부수고 다시 짓게 된다. + AddSection(content, "모델"); + + var anthropicRow = ChatUiBuilder.NewRect("AnthropicModels", content); + ChatUiBuilder.SetHeight(anthropicRow, SegmentHeight); + anthropicModelRow = anthropicRow.gameObject; + anthropicModelSegments = new Segmented(anthropicRow, AnthropicChatBackend.SelectableModels); + + var geminiRow = ChatUiBuilder.NewRect("GeminiModels", content); + ChatUiBuilder.SetHeight(geminiRow, SegmentHeight); + geminiModelRow = geminiRow.gameObject; + geminiModelSegments = new Segmented(geminiRow, GeminiChatBackend.SelectableModels); + + // --- 응답 깊이 (Claude 전용) --- + // Gemini 에는 대응하는 설정을 넣지 않았다. 모델 세대마다 thinkingConfig 모양이 + // 달라서 잘못 보내면 400 이 난다. 그래서 항목 자체를 숨긴다. + var effortContainer = ChatUiBuilder.NewRect("EffortGroup", content); + ChatUiBuilder.MakeVerticalList(effortContainer, new RectOffset(0, 0, 0, 0), 5f); + effortGroup = effortContainer.gameObject; + + AddSection(effortContainer, "응답 깊이"); + var effortRow = ChatUiBuilder.NewRect("EffortRow", effortContainer); + ChatUiBuilder.SetHeight(effortRow, SegmentHeight); + effortSegments = new Segmented(effortRow, AnthropicChatBackend.SelectableEfforts); + + // --- 성격 --- + AddSection(content, "성격 (캐릭터가 어떻게 말할지)"); + personaField = ChatUiBuilder.NewInputField("Persona", content, "예: 친근한 반말로 짧게 대답해줘", 12, true); + personaField.characterLimit = 4000; + ChatUiBuilder.SetHeight(personaField.transform, 130f); + + BuildFooter(); + + root.gameObject.SetActive(false); + } + + // ------------------------------------------------------------------ 열고 닫기 + + /// 현재 설정을 화면에 채우고 연다. 취소하면 이 객체는 건드려지지 않는다. + public void Open(ChatConfig config) + { + working = config ?? ChatConfig.Load(); + + // 순서가 중요하다. Select 는 값이 바뀌면 OnProviderChanged 를 부르고, 그 안의 + // StashKey 가 "입력칸에 지금 들어있는 글자"를 editingProvider 자리에 써넣는다. + // 입력칸을 먼저 채워두지 않으면 지난번에 열었을 때의 글자가 엉뚱한 제공자의 + // 키를 덮어쓴다. 먼저 채워두면 콜백이 돌아도 같은 값을 같은 자리에 다시 넣을 뿐이다. + editingProvider = working.provider; + keyField.text = KeyFor(editingProvider); + SetRevealed(false); + + providerSegments.Select(working.provider); + + anthropicModelSegments.Select(working.model); + geminiModelSegments.Select(working.geminiModel); + effortSegments.Select(working.effort); + personaField.text = working.persona ?? string.Empty; + + testResult.text = string.Empty; + ApplyProviderToScreen(editingProvider); + + root.gameObject.SetActive(true); + } + + public void Close() + { + if (root != null) root.gameObject.SetActive(false); + } + + void Apply() + { + if (working == null) return; + + StashKey(); + + working.provider = providerSegments.Value; + working.model = anthropicModelSegments.Value; + working.geminiModel = geminiModelSegments.Value; + working.effort = effortSegments.Value; + working.persona = personaField.text; + + SaveRequested?.Invoke(working); + } + + // ------------------------------------------------------------------ 제공자 전환 + + void OnProviderChanged(string providerId) + { + // 입력칸에 들고 있던 키를 원래 제공자 자리에 되돌려 놓고 새 제공자 것을 꺼낸다. + // 이걸 안 하면 제공자를 왔다갔다 할 때 방금 붙여넣은 키가 사라진다. + StashKey(); + editingProvider = providerId; + keyField.text = KeyFor(providerId); + SetRevealed(false); + + testResult.text = string.Empty; + ApplyProviderToScreen(providerId); + } + + /// 지금 입력칸의 값을 editingProvider 자리에 저장한다. + void StashKey() + { + if (working == null) return; + + string typed = keyField.text.Trim(); + if (editingProvider == ChatConfig.ProviderGemini) working.geminiApiKey = typed; + else working.apiKey = typed; + } + + string KeyFor(string providerId) + { + if (working == null) return string.Empty; + return (providerId == ChatConfig.ProviderGemini ? working.geminiApiKey : working.apiKey) + ?? string.Empty; + } + + /// 제공자에 맞게 보이는 항목과 안내 문구를 바꾼다. + void ApplyProviderToScreen(string providerId) + { + bool gemini = providerId == ChatConfig.ProviderGemini; + + anthropicModelRow.SetActive(!gemini); + geminiModelRow.SetActive(gemini); + effortGroup.SetActive(!gemini); + + if (keyPlaceholder != null) keyPlaceholder.text = gemini ? "AIza..." : "sk-ant-..."; + + RefreshKeyStatus(); + } + + void RefreshKeyStatus() + { + bool gemini = editingProvider == ChatConfig.ProviderGemini; + bool hasTyped = !string.IsNullOrWhiteSpace(keyField.text); + bool hasEnv = ChatConfig.EnvironmentKeyFor(editingProvider) != null; + + string where = gemini ? "aistudio.google.com" : "console.anthropic.com"; + string envName = gemini ? "GEMINI_API_KEY" : "ANTHROPIC_API_KEY"; + + if (hasTyped) + { + keyStatus.text = "저장하면 이 키를 씁니다."; + keyStatus.color = ChatUiTheme.DimText; + } + else if (hasEnv) + { + keyStatus.text = $"지금은 환경 변수 {envName} 를 쓰고 있어요.\n여기에 입력하면 그쪽이 우선합니다."; + keyStatus.color = ChatUiTheme.DimText; + } + else + { + keyStatus.text = $"키가 없어요. {where} 에서 발급받아 붙여넣어 주세요."; + keyStatus.color = new Color(0.93f, 0.72f, 0.42f, 1f); + } + + // 무료 티어의 대가는 결정하는 자리에서 알려야 의미가 있다. + if (gemini) + { + keyStatus.text += "\n※ 무료 등급은 주고받은 대화가 Google 제품 개선에 쓰입니다."; + } + } + + void ToggleReveal() + { + SetRevealed(keyField.contentType == InputField.ContentType.Password); + } + + void SetRevealed(bool revealed) + { + keyField.contentType = revealed + ? InputField.ContentType.Standard + : InputField.ContentType.Password; + + // contentType 을 바꾸면 표시 문자열을 다시 그려야 별표/원문이 실제로 바뀐다. + keyField.ForceLabelUpdate(); + + var label = revealButton.GetComponentInChildren(); + if (label != null) label.text = revealed ? "숨김" : "보기"; + } + + void RunTest() + { + if (TestRequested == null || working == null) return; + + // 아직 저장하지 않은 화면의 값으로 시험해야 사용자가 기대한 결과가 나온다. + string typed = keyField.text.Trim(); + bool gemini = providerSegments.Value == ChatConfig.ProviderGemini; + + var candidate = new ChatConfig { provider = providerSegments.Value }; + if (gemini) + { + candidate.geminiApiKey = typed; + candidate.geminiModel = geminiModelSegments.Value; + } + else + { + candidate.apiKey = typed; + candidate.model = anthropicModelSegments.Value; + } + + testButton.interactable = false; + testResult.text = "확인 중…"; + testResult.color = ChatUiTheme.DimText; + + TestRequested.Invoke(candidate, (ok, message) => + { + testButton.interactable = true; + testResult.text = message; + testResult.color = ok + ? new Color(0.55f, 0.85f, 0.6f, 1f) + : new Color(0.95f, 0.6f, 0.6f, 1f); + }); + } + + // ------------------------------------------------------------------ 계층 만들기 + + void BuildHeader() + { + var header = ChatUiBuilder.NewRect("Header", root); + header.anchorMin = new Vector2(0f, 1f); + header.anchorMax = Vector2.one; + header.pivot = new Vector2(0.5f, 1f); + header.sizeDelta = new Vector2(0f, HeaderHeight); + header.anchoredPosition = Vector2.zero; + + // 기호 대신 한글을 쓴다. OS 한글 글꼴에 없는 기호는 빈칸으로 나온다. + var back = ChatUiBuilder.NewButton("Back", header, "뒤로", 12, Color.clear, ChatUiTheme.DimText); + var backRect = back.GetComponent(); + backRect.anchorMin = new Vector2(0f, 0.5f); + backRect.anchorMax = new Vector2(0f, 0.5f); + backRect.pivot = new Vector2(0f, 0.5f); + backRect.sizeDelta = new Vector2(40f, HeaderHeight); + backRect.anchoredPosition = new Vector2(6f, 0f); + back.onClick.AddListener(() => CloseRequested?.Invoke()); + + var title = ChatUiBuilder.NewText("Title", header, "설정", 13, ChatUiTheme.DimText); + title.alignment = TextAnchor.MiddleCenter; + ChatUiBuilder.Stretch(title.rectTransform, 52f, 0f); + + 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; + } + + RectTransform BuildScrollArea() + { + var scrollRect = ChatUiBuilder.NewRect("Scroll", root); + scrollRect.anchorMin = Vector2.zero; + scrollRect.anchorMax = Vector2.one; + scrollRect.offsetMin = new Vector2(10f, FooterHeight); + scrollRect.offsetMax = new Vector2(-10f, -HeaderHeight); + + var scroll = scrollRect.gameObject.AddComponent(); + scroll.horizontal = false; + scroll.vertical = true; + scroll.movementType = ScrollRect.MovementType.Clamped; + scroll.scrollSensitivity = 26f; + + var viewport = ChatUiBuilder.NewRect("Viewport", scrollRect); + ChatUiBuilder.Stretch(viewport); + viewport.gameObject.AddComponent(); + + var content = ChatUiBuilder.NewRect("Content", viewport); + content.anchorMin = new Vector2(0f, 1f); + content.anchorMax = Vector2.one; + content.pivot = new Vector2(0.5f, 1f); + content.sizeDelta = Vector2.zero; + + ChatUiBuilder.MakeVerticalList(content, new RectOffset(0, 6, 6, 12), 5f); + var fitter = content.gameObject.AddComponent(); + fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + + scroll.viewport = viewport; + scroll.content = content; + return content; + } + + void BuildFooter() + { + var footer = ChatUiBuilder.NewRect("Footer", root); + footer.anchorMin = Vector2.zero; + footer.anchorMax = new Vector2(1f, 0f); + footer.pivot = new Vector2(0.5f, 0f); + footer.sizeDelta = new Vector2(0f, FooterHeight); + footer.anchoredPosition = Vector2.zero; + + var cancel = ChatUiBuilder.NewButton("Cancel", footer, "취소", 13, + ChatUiTheme.InputBackground, ChatUiTheme.DimText); + var cancelRect = cancel.GetComponent(); + cancelRect.anchorMin = new Vector2(0f, 0.5f); + cancelRect.anchorMax = new Vector2(0f, 0.5f); + cancelRect.pivot = new Vector2(0f, 0.5f); + cancelRect.sizeDelta = new Vector2(70f, 30f); + cancelRect.anchoredPosition = new Vector2(10f, 0f); + cancel.onClick.AddListener(() => CloseRequested?.Invoke()); + + var save = ChatUiBuilder.NewButton("Save", footer, "저장", 13, + ChatUiTheme.AccentButton, ChatUiTheme.PrimaryText); + var saveRect = save.GetComponent(); + saveRect.anchorMin = new Vector2(1f, 0.5f); + saveRect.anchorMax = new Vector2(1f, 0.5f); + saveRect.pivot = new Vector2(1f, 0.5f); + saveRect.sizeDelta = new Vector2(80f, 30f); + saveRect.anchoredPosition = new Vector2(-10f, 0f); + save.onClick.AddListener(Apply); + } + + static void AddSection(RectTransform content, string label) + { + var text = ChatUiBuilder.NewSectionLabel(label, content, label); + ChatUiBuilder.SetHeight(text.transform, 20f); + } + + /// + /// 몇 개 안 되는 선택지를 가로로 나눠 붙인 버튼 묶음. + /// + /// uGUI Dropdown 을 코드로 만들려면 템플릿 계층(뷰포트/스크롤/아이템/체크마크)을 + /// 통째로 손으로 세워야 한다. 선택지가 서너 개뿐이라 이쪽이 훨씬 간단하고, + /// 목록을 펼치지 않아도 현재 값이 보인다. + /// + class Segmented + { + readonly (string Label, string Id)[] options; + readonly Image[] backgrounds; + readonly Action onChanged; + + public string Value { get; private set; } + + public Segmented(RectTransform row, (string Label, string Id)[] options, + Action onChanged = null) + { + this.options = options; + this.onChanged = onChanged; + backgrounds = new Image[options.Length]; + + for (int i = 0; i < options.Length; i++) + { + int index = i; + var button = ChatUiBuilder.NewButton(options[i].Id, row, options[i].Label, 12, + ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText); + + var rect = button.GetComponent(); + rect.anchorMin = new Vector2(i / (float)options.Length, 0f); + rect.anchorMax = new Vector2((i + 1) / (float)options.Length, 1f); + rect.offsetMin = new Vector2(i == 0 ? 0f : 2f, 0f); + rect.offsetMax = new Vector2(i == options.Length - 1 ? 0f : -2f, 0f); + + backgrounds[i] = button.targetGraphic as Image; + button.onClick.AddListener(() => Select(this.options[index].Id)); + } + + // 생성 시점에는 알릴 상대가 아직 없으므로 콜백 없이 첫 항목만 칠한다. + Paint(0); + Value = options[0].Id; + } + + public void Select(string id) + { + int selected = -1; + for (int i = 0; i < options.Length; i++) + { + if (options[i].Id == id) { selected = i; break; } + } + + // 설정 파일에 목록에 없는 값이 적혀 있을 수 있다. 그때는 첫 항목으로 되돌린다. + if (selected < 0) selected = 0; + + bool changed = Value != options[selected].Id; + Value = options[selected].Id; + Paint(selected); + + if (changed) onChanged?.Invoke(Value); + } + + void Paint(int selected) + { + for (int i = 0; i < backgrounds.Length; i++) + { + if (backgrounds[i] == null) continue; + backgrounds[i].color = i == selected + ? ChatUiTheme.AccentButton + : ChatUiTheme.InputBackground; + } + } + } +} diff --git a/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta b/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta new file mode 100644 index 0000000..a8c4bcb --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7abd865888980df439c835020ee8d676 \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatTypes.cs b/Assets/02_Scripts/Chat/ChatTypes.cs new file mode 100644 index 0000000..ebd4b36 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatTypes.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +/// 대화 한 줄의 화자. API 의 role 문자열로 그대로 바뀐다. +public enum ChatRole +{ + User, + Assistant, +} + +/// 대화 기록 한 줄. +[Serializable] +public struct ChatMessage +{ + public ChatRole Role; + public string Text; + + public ChatMessage(ChatRole role, string text) + { + Role = role; + Text = text; + } + + /// API 가 쓰는 role 문자열. + public string RoleName => Role == ChatRole.User ? "user" : "assistant"; +} + +/// +/// 대화 백엔드의 유일한 창구. +/// +/// 지금 구현체는 Anthropic API 를 직접 치는 하나뿐이지만, +/// 나중에 ASR/TTS 를 붙이면서 .NET 8 사이드카로 옮길 때 이 인터페이스만 다시 구현하면 된다. +/// UI 와 대화 기록 관리는 백엔드가 무엇인지 전혀 모른다. +/// +/// 스트리밍이 인터페이스에 박혀 있는 이유: 첫 글자가 뜨기까지의 지연이 체감 품질을 +/// 좌우하고, 나중에 문장 경계에서 잘라 TTS 로 넘기려면 델타 단위 수신이 필수다. +/// +public interface IChatBackend +{ + /// 요청이 진행 중인지. true 면 새 요청을 보내지 않는다. + bool IsBusy { get; } + + /// + /// 대화 기록 전체를 보내고 응답을 스트리밍으로 받는다. + /// + /// 지금까지의 대화. 마지막 항목이 이번 사용자 발화다. + /// 응답 조각이 도착할 때마다. 이어 붙이면 전체 응답이 된다. + /// 응답이 끝났을 때. 인자는 이어 붙인 전체 응답. + /// 실패 사유. 사용자에게 그대로 보여줄 수 있는 문장이어야 한다. + void Send(IReadOnlyList history, + Action onDelta, + Action onComplete, + Action onError); + + /// 진행 중인 요청을 중단한다. 진행 중이 아니면 아무것도 하지 않는다. + void Cancel(); + + /// + /// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 쓴다. + /// + /// 인터페이스에 넣은 이유: 무엇이 유효한 자격증명인지는 제공자마다 다르고, + /// 설정 화면은 그걸 알 필요가 없어야 한다. + /// + /// 아직 저장하지 않은, 화면에 입력된 값. + /// (성공 여부, 사용자에게 보여줄 문장). + void TestConnection(ChatConfig candidate, Action onResult); +} diff --git a/Assets/02_Scripts/Chat/ChatTypes.cs.meta b/Assets/02_Scripts/Chat/ChatTypes.cs.meta new file mode 100644 index 0000000..dc64c03 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatTypes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7262648598a182145b82d1d1ddca6b52 \ No newline at end of file diff --git a/Assets/02_Scripts/Chat/ChatUiBuilder.cs b/Assets/02_Scripts/Chat/ChatUiBuilder.cs new file mode 100644 index 0000000..5cd6842 --- /dev/null +++ b/Assets/02_Scripts/Chat/ChatUiBuilder.cs @@ -0,0 +1,136 @@ +using UnityEngine; +using UnityEngine.UI; + +/// +/// uGUI 조각을 코드로 만드는 공용 부품. 채팅창과 설정창이 같은 것을 쓴다. +/// +/// 에셋(프리팹/스프라이트/폰트)을 하나도 만들지 않는 것이 이 프로젝트의 방침이다. +/// 이유는 ChatUiTheme 에 적어뒀다. +/// +public static class ChatUiBuilder +{ + public static RectTransform NewRect(string name, Transform parent) + { + var go = new GameObject(name, typeof(RectTransform)); + var rect = go.GetComponent(); + rect.SetParent(parent, false); + return rect; + } + + /// 부모를 가득 채우도록 늘린다. + public static void Stretch(RectTransform rect, float horizontal = 0f, float vertical = 0f) + { + rect.anchorMin = Vector2.zero; + rect.anchorMax = Vector2.one; + rect.offsetMin = new Vector2(horizontal, vertical); + rect.offsetMax = new Vector2(-horizontal, -vertical); + } + + public static Image NewImage(string name, Transform parent, Sprite sprite, Color color) + { + var rect = NewRect(name, parent); + var image = rect.gameObject.AddComponent(); + image.sprite = sprite; + if (sprite != null) image.type = Image.Type.Sliced; + image.color = color; + return image; + } + + public static Text NewText(string name, Transform parent, string value, int fontSize, Color color) + { + var rect = NewRect(name, parent); + var text = rect.gameObject.AddComponent(); + text.font = ChatUiTheme.Font; + text.fontSize = fontSize; + text.color = color; + text.text = value; + text.supportRichText = false; + // 글자가 스크롤 드래그나 버튼 클릭을 가로채지 않게 한다. + text.raycastTarget = false; + return text; + } + + public static Button NewButton(string name, Transform parent, string label, int fontSize, + Color background, Color foreground) + { + var image = NewImage(name, parent, ChatUiTheme.RoundedSmall, background); + var rect = image.rectTransform; + + var button = rect.gameObject.AddComponent