添加询问李诫(AI 问答)功能

- 添加 AIChatComponent:用于主要的逻辑(构造请求,解析响应)
- 添加 AIChatForm:用于问答的页面展示
This commit is contained in:
SepComet
2026-02-25 17:25:09 +08:00
parent b00ea1a14f
commit f8c0f46c36
70 changed files with 7059 additions and 607 deletions
@@ -16,11 +16,13 @@ public partial class GameEntry : MonoBehaviour
public static BuiltinDataComponent BuiltinData { get; private set; }
public static CombineComponent Combine { get; private set; }
public static DialogComponent Dialog { get; private set; }
public static AIChatComponent AIChat { get; private set; }
private static void InitCustomComponents()
{
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
Combine = UnityGameFramework.Runtime.GameEntry.GetComponent<CombineComponent>();
Dialog = UnityGameFramework.Runtime.GameEntry.GetComponent<DialogComponent>();
AIChat = UnityGameFramework.Runtime.GameEntry.GetComponent<AIChatComponent>();
}
}
@@ -0,0 +1,534 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Definition.DataStruct;
using GameFramework;
using Network;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace CustomComponent
{
[DisallowMultipleComponent]
public class AIChatComponent : GameFrameworkComponent
{
[Header("OpenAI Config")] [SerializeField]
private string _apiBaseUrl = "https://api.openai.com";
[SerializeField] private string _apiKey = string.Empty;
[SerializeField] private string _chatCompletionsPath = "/v1/chat/completions";
[SerializeField] private string _heartbeatPath = "/v1/models";
[SerializeField] private string _model = "gpt-4o-mini";
[Header("Request Options")] [SerializeField]
private bool _autoInitializeOnEnable = true;
[SerializeField] private int _chatTimeoutSeconds = 60;
[SerializeField] private float _temperature = 0.7f;
[SerializeField] [TextArea(2, 6)] private string _systemPrompt = string.Empty;
[Header("Heartbeat")] [SerializeField] private float _heartbeatIntervalSeconds = 5f;
[SerializeField] private int _heartbeatTimeoutSeconds = 8;
private readonly List<OpenAIStreamChunk> _chunkCache = new List<OpenAIStreamChunk>();
private readonly StringBuilder _assistantResponseCache = new StringBuilder();
private readonly Queue<string> _pendingSsePayloads = new Queue<string>();
private readonly object _streamQueueLock = new object();
private readonly OpenAIHttpNetworkService _networkService = new OpenAIHttpNetworkService();
private Coroutine _heartbeatCoroutine;
private Coroutine _chatCoroutine;
private CancellationTokenSource _heartbeatRequestCancellation;
private CancellationTokenSource _chatRequestCancellation;
private bool _isInitialized;
private bool _isHeartbeatRequesting;
private bool _streamDoneReceived;
public bool IsInitialized => _isInitialized;
public bool IsRequesting => _chatCoroutine != null;
public bool IsEndpointReachable { get; private set; }
public bool IsConnectionValid { get; private set; }
public long LastHeartbeatStatusCode { get; private set; }
public string LastHeartbeatErrorMessage { get; private set; } = string.Empty;
public string LastRequestErrorMessage { get; private set; } = string.Empty;
public string CachedResponseText => _assistantResponseCache.ToString();
public IReadOnlyList<OpenAIStreamChunk> CachedChunks => _chunkCache;
public event Action<OpenAIStreamChunk> StreamChunkReceived;
public event Action<string> StreamTextUpdated;
public event Action<string> StreamRequestCompleted;
public event Action<string> StreamRequestFailed;
private void OnEnable()
{
if (_autoInitializeOnEnable)
{
Initialize();
}
}
private void OnDisable()
{
StopHeartbeat();
CancelCurrentRequest();
}
private void OnDestroy()
{
StopHeartbeat();
CancelCurrentRequest();
_networkService.Dispose();
}
public void Initialize()
{
_isInitialized = true;
StartHeartbeat();
}
public void StartHeartbeat()
{
if (_heartbeatCoroutine != null)
{
return;
}
_heartbeatCoroutine = StartCoroutine(HeartbeatLoop());
}
public void StopHeartbeat()
{
if (_heartbeatRequestCancellation != null && !_heartbeatRequestCancellation.IsCancellationRequested)
{
_heartbeatRequestCancellation.Cancel();
}
if (_heartbeatCoroutine == null)
{
return;
}
StopCoroutine(_heartbeatCoroutine);
_heartbeatCoroutine = null;
}
public void TriggerHeartbeat()
{
StartCoroutine(SendHeartbeatOnce());
}
public bool SendChat(string userInput)
{
return SendChat(userInput, string.Empty);
}
public bool SendChat(string userInput, string extraSystemPrompt)
{
if (string.IsNullOrWhiteSpace(userInput))
{
LastRequestErrorMessage = "AI chat request failed. userInput is empty.";
StreamRequestFailed?.Invoke(LastRequestErrorMessage);
Log.Warning(LastRequestErrorMessage);
return false;
}
if (_chatCoroutine != null)
{
LastRequestErrorMessage = "AI chat request failed. Previous request is still running.";
StreamRequestFailed?.Invoke(LastRequestErrorMessage);
Log.Warning(LastRequestErrorMessage);
return false;
}
_chatCoroutine = StartCoroutine(SendChatCoroutine(userInput, extraSystemPrompt));
return true;
}
public void CancelCurrentRequest()
{
if (_chatRequestCancellation != null && !_chatRequestCancellation.IsCancellationRequested)
{
_chatRequestCancellation.Cancel();
}
}
public void ClearChatCache()
{
_chunkCache.Clear();
_assistantResponseCache.Length = 0;
_streamDoneReceived = false;
lock (_streamQueueLock)
{
_pendingSsePayloads.Clear();
}
}
public string BuildOpenAIRequestBody(string userInput)
{
return BuildOpenAIRequestBody(userInput, string.Empty);
}
public string BuildOpenAIRequestBody(string userInput, string extraSystemPrompt)
{
OpenAIChatRequest requestBody = BuildRequestData(userInput, extraSystemPrompt);
return Utility.Json.ToJson(requestBody);
}
private IEnumerator HeartbeatLoop()
{
while (true)
{
yield return SendHeartbeatOnce();
yield return new WaitForSecondsRealtime(Mathf.Max(0.1f, _heartbeatIntervalSeconds));
}
}
private IEnumerator SendHeartbeatOnce()
{
if (_isHeartbeatRequesting)
{
yield break;
}
_isHeartbeatRequesting = true;
try
{
string heartbeatUrl = BuildEndpointUrl(_heartbeatPath);
if (string.IsNullOrWhiteSpace(heartbeatUrl))
{
UpdateHeartbeatState(false, false, 0, "Heartbeat failed. URL is empty.");
yield break;
}
if (_heartbeatRequestCancellation != null)
{
_heartbeatRequestCancellation.Dispose();
_heartbeatRequestCancellation = null;
}
_heartbeatRequestCancellation = new CancellationTokenSource();
Task<OpenAIHeartbeatResult> heartbeatTask = _networkService.SendHeartbeatAsync(
heartbeatUrl,
_apiKey,
_heartbeatTimeoutSeconds,
_heartbeatRequestCancellation.Token);
yield return WaitTask(heartbeatTask);
if (heartbeatTask.IsFaulted)
{
UpdateHeartbeatState(false, false, 0, GetTaskErrorMessage(heartbeatTask.Exception));
}
else if (heartbeatTask.IsCanceled)
{
UpdateHeartbeatState(false, false, 0, "Heartbeat canceled.");
}
else
{
OpenAIHeartbeatResult result = heartbeatTask.Result;
UpdateHeartbeatState(result.IsEndpointReachable, result.IsConnectionValid, result.StatusCode,
result.ErrorMessage);
}
}
finally
{
if (_heartbeatRequestCancellation != null)
{
_heartbeatRequestCancellation.Dispose();
_heartbeatRequestCancellation = null;
}
_isHeartbeatRequesting = false;
}
}
private IEnumerator SendChatCoroutine(string userInput, string extraSystemPrompt)
{
LastRequestErrorMessage = string.Empty;
ClearChatCache();
string chatUrl = BuildEndpointUrl(_chatCompletionsPath);
if (string.IsNullOrWhiteSpace(chatUrl))
{
LastRequestErrorMessage = "AI chat request failed. URL is empty.";
StreamRequestFailed?.Invoke(LastRequestErrorMessage);
Log.Warning(LastRequestErrorMessage);
_chatCoroutine = null;
yield break;
}
if (_chatRequestCancellation != null)
{
_chatRequestCancellation.Dispose();
_chatRequestCancellation = null;
}
_chatRequestCancellation = new CancellationTokenSource();
string requestBody = BuildOpenAIRequestBody(userInput, extraSystemPrompt);
try
{
Task<OpenAIStreamResult> requestTask = _networkService.StreamChatAsync(
chatUrl,
_apiKey,
requestBody,
_chatTimeoutSeconds,
EnqueueSsePayload,
_chatRequestCancellation.Token);
while (!requestTask.IsCompleted)
{
FlushPendingSsePayloads();
yield return null;
}
FlushPendingSsePayloads();
if (requestTask.IsFaulted)
{
LastRequestErrorMessage = GetTaskErrorMessage(requestTask.Exception);
StreamRequestFailed?.Invoke(LastRequestErrorMessage);
Log.Warning("AI chat request failed. {0}", LastRequestErrorMessage);
yield break;
}
OpenAIStreamResult result = requestTask.Result;
if (!result.IsSuccess)
{
LastRequestErrorMessage = BuildRequestErrorMessage(result);
StreamRequestFailed?.Invoke(LastRequestErrorMessage);
Log.Warning("AI chat request failed. {0}", LastRequestErrorMessage);
yield break;
}
if (!_streamDoneReceived && _chunkCache.Count > 0)
{
Log.Warning("AI chat stream completed without receiving [DONE] marker.");
}
StreamRequestCompleted?.Invoke(_assistantResponseCache.ToString());
}
finally
{
if (_chatRequestCancellation != null)
{
_chatRequestCancellation.Dispose();
_chatRequestCancellation = null;
}
_chatCoroutine = null;
}
}
private void EnqueueSsePayload(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return;
}
lock (_streamQueueLock)
{
_pendingSsePayloads.Enqueue(payload);
}
}
private void FlushPendingSsePayloads()
{
while (true)
{
string payload;
lock (_streamQueueLock)
{
if (_pendingSsePayloads.Count <= 0)
{
break;
}
payload = _pendingSsePayloads.Dequeue();
}
ParseSsePayload(payload);
}
}
private void ParseSsePayload(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return;
}
if (string.Equals(payload, "[DONE]", StringComparison.Ordinal))
{
_streamDoneReceived = true;
return;
}
OpenAIStreamChunk chunk;
try
{
chunk = Utility.Json.ToObject<OpenAIStreamChunk>(payload);
}
catch (Exception exception)
{
Log.Warning("AI chat chunk parse failed. reason='{0}'", exception.Message);
return;
}
if (chunk == null)
{
return;
}
_chunkCache.Add(chunk);
StreamChunkReceived?.Invoke(chunk);
string textDelta = ExtractTextDelta(chunk);
if (string.IsNullOrEmpty(textDelta))
{
return;
}
_assistantResponseCache.Append(textDelta);
StreamTextUpdated?.Invoke(textDelta);
}
private void UpdateHeartbeatState(bool endpointReachable, bool connectionValid, long statusCode,
string errorMessage)
{
IsEndpointReachable = endpointReachable;
IsConnectionValid = connectionValid;
LastHeartbeatStatusCode = statusCode;
LastHeartbeatErrorMessage = errorMessage ?? string.Empty;
}
private OpenAIChatRequest BuildRequestData(string userInput, string extraSystemPrompt)
{
OpenAIChatRequest request = new OpenAIChatRequest
{
model = _model,
stream = true,
temperature = (double)Mathf.Clamp(_temperature, 0f, 2f),
messages = new List<OpenAIMessage>()
};
if (!string.IsNullOrWhiteSpace(_systemPrompt))
{
request.messages.Add(new OpenAIMessage
{
role = "system",
content = _systemPrompt
});
}
if (!string.IsNullOrWhiteSpace(extraSystemPrompt))
{
request.messages.Add(new OpenAIMessage
{
role = "system",
content = extraSystemPrompt
});
}
request.messages.Add(new OpenAIMessage
{
role = "user",
content = userInput
});
return request;
}
private string BuildEndpointUrl(string pathOrUrl)
{
if (string.IsNullOrWhiteSpace(pathOrUrl))
{
return string.Empty;
}
if (pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
return pathOrUrl.Trim();
}
if (string.IsNullOrWhiteSpace(_apiBaseUrl))
{
return string.Empty;
}
return Utility.Text.Format("{0}/{1}", _apiBaseUrl.TrimEnd('/'), pathOrUrl.TrimStart('/'));
}
private static IEnumerator WaitTask(Task task)
{
while (!task.IsCompleted)
{
yield return null;
}
}
private static string GetTaskErrorMessage(AggregateException exception)
{
if (exception == null)
{
return "Unknown task error.";
}
Exception baseException = exception.GetBaseException();
return baseException != null ? baseException.Message : exception.Message;
}
private static string BuildRequestErrorMessage(OpenAIStreamResult result)
{
if (result == null)
{
return "AI chat request failed. Result is null.";
}
if (result.IsCanceled)
{
return "AI chat request canceled.";
}
if (string.IsNullOrEmpty(result.ErrorBody))
{
return string.Format("{0} (status={1})", result.ErrorMessage, result.StatusCode.ToString());
}
return string.Format("{0} (status={1}) body={2}", result.ErrorMessage, result.StatusCode.ToString(),
result.ErrorBody);
}
private static string ExtractTextDelta(OpenAIStreamChunk chunk)
{
if (chunk == null || chunk.choices == null || chunk.choices.Length == 0)
{
return string.Empty;
}
StringBuilder builder = null;
foreach (var choice in chunk.choices)
{
if (choice == null || choice.delta == null || string.IsNullOrEmpty(choice.delta.content))
{
continue;
}
if (builder == null)
{
builder = new StringBuilder();
}
builder.Append(choice.delta.content);
}
return builder != null ? builder.ToString() : string.Empty;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 721362e43780c4043ae6e7ce92463755
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace Definition.DataStruct
{
[Serializable]
public sealed class OpenAIChatRequest
{
public string model;
public bool stream;
public double temperature;
public List<OpenAIMessage> messages;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4ae65566eea74645bfbebbd8edc2eeb2
timeCreated: 1772005238
@@ -0,0 +1,11 @@
using System;
namespace Definition.DataStruct
{
[Serializable]
public sealed class OpenAIMessage
{
public string role;
public string content;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: da985afe4cdd41b985cdc55b16669248
timeCreated: 1772005281
@@ -0,0 +1,12 @@
using System;
namespace Definition.DataStruct
{
[Serializable]
public sealed class OpenAIStreamChoice
{
public int index;
public OpenAIStreamDelta delta;
public string finish_reason;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 68ead73e1887412caf82bc156fe7960b
timeCreated: 1772005319
@@ -0,0 +1,14 @@
using System;
namespace Definition.DataStruct
{
[Serializable]
public sealed class OpenAIStreamChunk
{
public string id;
public string @object;
public int created;
public string model;
public OpenAIStreamChoice[] choices;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8aaa860de9da4771a22469c918a8346e
timeCreated: 1772005300
@@ -0,0 +1,11 @@
using System;
namespace Definition.DataStruct
{
[Serializable]
public sealed class OpenAIStreamDelta
{
public string role;
public string content;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 151022fd6325477aa9810f1914030419
timeCreated: 1772005339
@@ -55,5 +55,7 @@ namespace UI
BubbleDialogForm = 106,
MainOverlayForm = 107,
AIChatForm = 108,
}
}
@@ -5,7 +5,7 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
namespace StarForce
namespace Network
{
public abstract class CSPacketBase : PacketBase
{
@@ -5,7 +5,7 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
namespace StarForce
namespace Network
{
public sealed class CSPacketHeader : PacketHeaderBase
{
@@ -16,7 +16,7 @@ using System.IO;
using System.Reflection;
using UnityGameFramework.Runtime;
namespace StarForce
namespace Network
{
public class NetworkChannelHelper : INetworkChannelHelper
{
@@ -0,0 +1,340 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Network
{
public sealed class OpenAIHttpNetworkService : IDisposable
{
private readonly HttpClient _httpClient;
private bool _disposed;
public OpenAIHttpNetworkService()
{
HttpClientHandler handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
_httpClient = new HttpClient(handler);
}
public async Task<OpenAIHeartbeatResult> SendHeartbeatAsync(string url, string apiKey, int timeoutSeconds,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(url))
{
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, "Heartbeat failed. URL is empty.");
}
using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url))
{
ApplyAuthorization(request, apiKey);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using (CancellationTokenSource timeoutCts = new CancellationTokenSource(
TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))))
{
using (CancellationTokenSource linkedCts =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
{
try
{
using (HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
.ConfigureAwait(false))
{
long statusCode = (long)response.StatusCode;
bool isHttpSuccess = response.IsSuccessStatusCode;
if (isHttpSuccess)
{
return OpenAIHeartbeatResult.CreateSuccess(statusCode);
}
if (statusCode == 401 || statusCode == 403)
{
return OpenAIHeartbeatResult.CreateFailure(true, false, statusCode,
"Authentication failed. API key may be invalid.");
}
string errorBody = await TryReadContentAsync(response.Content).ConfigureAwait(false);
string errorMessage = string.IsNullOrEmpty(errorBody)
? string.Format("Heartbeat failed with HTTP {0}.", statusCode.ToString())
: string.Format("Heartbeat failed with HTTP {0}. body={1}", statusCode.ToString(),
errorBody);
return OpenAIHeartbeatResult.CreateFailure(true, false, statusCode, errorMessage);
}
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
return OpenAIHeartbeatResult.CreateFailure(false, false, 0,
"Heartbeat canceled by caller.");
}
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, "Heartbeat timeout.");
}
catch (HttpRequestException exception)
{
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, exception.Message);
}
catch (Exception exception)
{
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, exception.Message);
}
}
}
}
}
public async Task<OpenAIStreamResult> StreamChatAsync(
string url,
string apiKey,
string requestBodyJson,
int timeoutSeconds,
Action<string> onSseDataPayload,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(url))
{
return OpenAIStreamResult.CreateFailure(false, false, 0, "AI chat request failed. URL is empty.",
string.Empty);
}
using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url))
{
ApplyAuthorization(request, apiKey);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Content = new StringContent(requestBodyJson ?? string.Empty, Encoding.UTF8, "application/json");
using (CancellationTokenSource timeoutCts = new CancellationTokenSource(
TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))))
{
using (CancellationTokenSource linkedCts =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
{
try
{
using (HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
.ConfigureAwait(false))
{
long statusCode = (long)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
string errorBody = await TryReadContentAsync(response.Content).ConfigureAwait(false);
string errorMessage = string.Format("HTTP {0} {1}", statusCode.ToString(),
response.ReasonPhrase ?? string.Empty);
return OpenAIStreamResult.CreateFailure(true, false, statusCode, errorMessage,
errorBody);
}
using (Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
{
while (true)
{
string line =
await WaitReadLineAsync(reader, linkedCts.Token).ConfigureAwait(false);
if (line == null)
{
break;
}
if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
string payload = line.Substring(5).Trim();
if (string.IsNullOrEmpty(payload))
{
continue;
}
onSseDataPayload?.Invoke(payload);
}
}
return OpenAIStreamResult.CreateSuccess(statusCode);
}
}
catch (OperationCanceledException)
{
return OpenAIStreamResult.CreateCanceled();
}
catch (HttpRequestException exception)
{
return OpenAIStreamResult.CreateFailure(false, false, 0, exception.Message, string.Empty);
}
catch (Exception exception)
{
return OpenAIStreamResult.CreateFailure(false, false, 0, exception.Message, string.Empty);
}
}
}
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private static void ApplyAuthorization(HttpRequestMessage request, string apiKey)
{
if (request == null || string.IsNullOrWhiteSpace(apiKey))
{
return;
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
}
private static async Task<string> WaitReadLineAsync(StreamReader reader, CancellationToken cancellationToken)
{
Task<string> readLineTask = reader.ReadLineAsync();
if (readLineTask.IsCompleted || !cancellationToken.CanBeCanceled)
{
return await readLineTask.ConfigureAwait(false);
}
TaskCompletionSource<bool> cancellationTaskSource = new TaskCompletionSource<bool>();
using (cancellationToken.Register(() => cancellationTaskSource.TrySetResult(true)))
{
Task completedTask = await Task.WhenAny(readLineTask, cancellationTaskSource.Task).ConfigureAwait(false);
if (completedTask != readLineTask)
{
throw new OperationCanceledException(cancellationToken);
}
}
return await readLineTask.ConfigureAwait(false);
}
private static async Task<string> TryReadContentAsync(HttpContent content)
{
if (content == null)
{
return string.Empty;
}
try
{
return await content.ReadAsStringAsync().ConfigureAwait(false);
}
catch
{
return string.Empty;
}
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_httpClient.Dispose();
}
_disposed = true;
}
}
public sealed class OpenAIHeartbeatResult
{
public bool IsEndpointReachable { get; private set; }
public bool IsConnectionValid { get; private set; }
public long StatusCode { get; private set; }
public string ErrorMessage { get; private set; }
public static OpenAIHeartbeatResult CreateSuccess(long statusCode)
{
return new OpenAIHeartbeatResult
{
IsEndpointReachable = true,
IsConnectionValid = true,
StatusCode = statusCode,
ErrorMessage = string.Empty
};
}
public static OpenAIHeartbeatResult CreateFailure(bool endpointReachable, bool connectionValid, long statusCode,
string errorMessage)
{
return new OpenAIHeartbeatResult
{
IsEndpointReachable = endpointReachable,
IsConnectionValid = connectionValid,
StatusCode = statusCode,
ErrorMessage = errorMessage ?? string.Empty
};
}
}
public sealed class OpenAIStreamResult
{
public bool IsSuccess { get; private set; }
public bool IsCanceled { get; private set; }
public bool IsEndpointReachable { get; private set; }
public bool IsConnectionValid { get; private set; }
public long StatusCode { get; private set; }
public string ErrorMessage { get; private set; }
public string ErrorBody { get; private set; }
public static OpenAIStreamResult CreateSuccess(long statusCode)
{
return new OpenAIStreamResult
{
IsSuccess = true,
IsCanceled = false,
IsEndpointReachable = true,
IsConnectionValid = true,
StatusCode = statusCode,
ErrorMessage = string.Empty,
ErrorBody = string.Empty
};
}
public static OpenAIStreamResult CreateCanceled()
{
return new OpenAIStreamResult
{
IsSuccess = false,
IsCanceled = true,
IsEndpointReachable = false,
IsConnectionValid = false,
StatusCode = 0,
ErrorMessage = "Request canceled.",
ErrorBody = string.Empty
};
}
public static OpenAIStreamResult CreateFailure(bool endpointReachable, bool connectionValid, long statusCode,
string errorMessage, string errorBody)
{
return new OpenAIStreamResult
{
IsSuccess = false,
IsCanceled = false,
IsEndpointReachable = endpointReachable,
IsConnectionValid = connectionValid,
StatusCode = statusCode,
ErrorMessage = errorMessage ?? string.Empty,
ErrorBody = errorBody ?? string.Empty
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b1df451f58f9f04ea0357989526f6a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -8,7 +8,7 @@
using ProtoBuf;
using System;
namespace StarForce
namespace Network
{
[Serializable, ProtoContract(Name = @"CSHeartBeat")]
public class CSHeartBeat : CSPacketBase
@@ -8,7 +8,7 @@
using ProtoBuf;
using System;
namespace StarForce
namespace Network
{
[Serializable, ProtoContract(Name = @"SCHeartBeat")]
public class SCHeartBeat : SCPacketBase
@@ -8,7 +8,7 @@
using GameFramework.Network;
using ProtoBuf;
namespace StarForce
namespace Network
{
public abstract class PacketBase : Packet, IExtensible
{
@@ -8,7 +8,7 @@
using GameFramework.Network;
using UnityGameFramework.Runtime;
namespace StarForce
namespace Network
{
public class SCHeartBeatHandler : PacketHandlerBase
{
@@ -7,7 +7,7 @@
using GameFramework.Network;
namespace StarForce
namespace Network
{
public abstract class PacketHandlerBase : IPacketHandler
{
@@ -8,7 +8,7 @@
using GameFramework;
using GameFramework.Network;
namespace StarForce
namespace Network
{
public abstract class PacketHeaderBase : IPacketHeader, IReference
{
@@ -5,7 +5,7 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
namespace StarForce
namespace Network
{
public enum PacketType : byte
{
@@ -5,7 +5,7 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
namespace StarForce
namespace Network
{
public abstract class SCPacketBase : PacketBase
{
@@ -5,7 +5,7 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
namespace StarForce
namespace Network
{
public sealed class SCPacketHeader : PacketHeaderBase
{
@@ -1,21 +1,12 @@
using Definition.Enum;
using System.Collections.Generic;
using CustomComponent;
using Event;
using GameFramework.Event;
using UI;
using UnityEngine;
using UnityGameFramework.Runtime;
using ProcedureOwner = GameFramework.Fsm.IFsm<GameFramework.Procedure.IProcedureManager>;
namespace Procedure
{
/// <summary>
/// 斗拱拼装流程:
/// 1. 进入流程后准备组件与事件;
/// 2. 启动并监控拼装关卡(成功或超时);
/// 3. 结算后短暂等待并重开场景。
/// </summary>
public class ProcedureCombine : ProcedureBase
{
#region Property
@@ -40,12 +31,17 @@ namespace Procedure
protected override void OnEnter(ProcedureOwner procedureOwner)
{
base.OnEnter(procedureOwner);
//InitializeProcedureState();
GameEntry.Dialog.Init(1);
//GameEntry.Dialog.Init(1);
//GameEntry.Dialog.StartDialog(1001);
GameEntry.Dialog.StartDialog(1002);
//GameEntry.Dialog.StartDialog(1002);
AIChatFormContext context = new AIChatFormContext();
AIChatFormController controller = new AIChatFormController();
context.Controller = controller;
controller.OpenUI(context);
}
/// <summary>
+16 -10
View File
@@ -11,17 +11,20 @@ using UnityEngine.EventSystems;
namespace UI
{
public class CommonButton : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler
public class CommonButton : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler,
IPointerUpHandler
{
private const float FadeTime = 0.3f;
private const float OnHoverAlpha = 0.7f;
private const float OnClickAlpha = 0.6f;
[SerializeField]
private UnityEvent m_OnHover = null;
[SerializeField] private bool _allowFade = true;
[SerializeField]
private UnityEvent m_OnClick = null;
[SerializeField] private UnityEvent m_OnHover = null;
[SerializeField] private UnityEvent m_OnClick = null;
[SerializeField] private UnityEvent m_OnHoverEnd = null;
private CanvasGroup m_CanvasGroup = null;
@@ -43,8 +46,9 @@ namespace UI
}
StopAllCoroutines();
StartCoroutine(m_CanvasGroup.FadeToAlpha(OnHoverAlpha, FadeTime));
m_OnHover.Invoke();
if (_allowFade)
StartCoroutine(m_CanvasGroup.FadeToAlpha(OnHoverAlpha, FadeTime));
m_OnHover?.Invoke();
}
public void OnPointerExit(PointerEventData eventData)
@@ -55,7 +59,9 @@ namespace UI
}
StopAllCoroutines();
StartCoroutine(m_CanvasGroup.FadeToAlpha(1f, FadeTime));
if (_allowFade)
StartCoroutine(m_CanvasGroup.FadeToAlpha(1f, FadeTime));
m_OnHoverEnd?.Invoke();
}
public void OnPointerDown(PointerEventData eventData)
@@ -66,7 +72,7 @@ namespace UI
}
m_CanvasGroup.alpha = OnClickAlpha;
m_OnClick.Invoke();
m_OnClick?.Invoke();
}
public void OnPointerUp(PointerEventData eventData)
@@ -79,4 +85,4 @@ namespace UI
m_CanvasGroup.alpha = OnHoverAlpha;
}
}
}
}
@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace UI
{
public class AIChatFormContext : UIContext
{
public AIChatFormController Controller;
public string Title = "AI Chat";
public bool ClearHistoryOnOpen = true;
public int LanguageMode = 0;
public List<AIChatMessageContext> Messages = new List<AIChatMessageContext>();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bd7b7032876cfea4fba511fe86a86ca4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
namespace UI
{
public sealed class AIChatMessageContext : UIContext
{
public bool IsPlayer;
public string Content;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0b757f1a76817344396df2fbc235f7a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
namespace UI
{
public class AIDialogItemContext : UIContext
{
public float ParentWidth;
public string Content;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2740182b0799e4b4288755e07fd7244b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
namespace UI
{
public class PlayerDialogItemContext : UIContext
{
public string Content;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 10f44b172e664044fa1747d2e45f8c4f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,376 @@
using System.Collections.Generic;
using System.Text;
using CustomComponent;
using GameFramework.Event;
using UnityGameFramework.Runtime;
namespace UI
{
public class AIChatFormController : IFormController<AIChatFormContext>
{
private AIChatFormContext _context;
private AIChatForm _aiChatForm;
private int? _formSerialId;
private bool _pendingRefresh;
private bool _aiChatEventsBound;
private bool _isStreaming;
private int _streamingMessageIndex = -1;
private readonly StringBuilder _streamingMessageBuffer = new StringBuilder();
public AIChatFormController()
{
GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OnOpenUIFormSuccess);
GameEntry.Event.Subscribe(CloseUIFormCompleteEventArgs.EventId, OnCloseUIFormComplete);
}
public int? OpenUI(AIChatFormContext context)
{
if (context == null)
{
Log.Warning("AIChatFormController open failed. context is null.");
return null;
}
EnsureAIChatEventBinding();
context.Controller = this;
context.Messages ??= new List<AIChatMessageContext>();
_context = context;
if (_aiChatForm != null)
{
_aiChatForm.RefreshUI(_context);
return _formSerialId;
}
CloseUI();
_pendingRefresh = true;
_formSerialId = GameEntry.UI.OpenUIForm(UIFormId.AIChatForm, context);
return _formSerialId;
}
public void CloseUI()
{
_pendingRefresh = false;
if (_formSerialId.HasValue)
{
GameEntry.UI.CloseUIForm(_formSerialId.Value);
return;
}
if (_aiChatForm != null)
{
_aiChatForm.Close();
}
}
public void SubmitPlayerMessage(string input, int languageMode)
{
string trimmedInput = input == null ? string.Empty : input.Trim();
if (string.IsNullOrEmpty(trimmedInput))
{
return;
}
if (_isStreaming)
{
PublishErrorMessage("AI is still responding. Please wait.");
return;
}
EnsureAIChatEventBinding();
if (_context == null)
{
_context = new AIChatFormContext
{
Controller = this,
LanguageMode = 0,
Messages = new List<AIChatMessageContext>()
};
}
_context.LanguageMode = languageMode;
AddMessageToContext(true, trimmedInput);
_aiChatForm?.AppendPlayerDialog(new PlayerDialogItemContext
{
Content = trimmedInput
});
AIChatComponent aiChat = GameEntry.AIChat;
if (aiChat == null)
{
PublishErrorMessage("AIChatComponent is missing.");
return;
}
bool sendStarted = aiChat.SendChat(trimmedInput, BuildLanguageInstruction(languageMode));
if (!sendStarted)
{
PublishErrorMessage(string.IsNullOrEmpty(aiChat.LastRequestErrorMessage)
? "Failed to start AI chat request."
: aiChat.LastRequestErrorMessage);
return;
}
_isStreaming = true;
_streamingMessageIndex = -1;
_streamingMessageBuffer.Length = 0;
_aiChatForm?.SetInputInteractable(false);
}
private static string BuildLanguageInstruction(int languageMode)
{
if (languageMode == 1)
{
return "\u8bf7\u4f7f\u7528\u6587\u8a00\u6587\u56de\u7b54\uff0c\u8bed\u8a00\u5c3d\u91cf\u7b80\u6d01\u3001\u96c5\u6b63\u3001\u901a\u987a\u3002";
}
return "\u8bf7\u4f7f\u7528\u73b0\u4ee3\u767d\u8bdd\u6587\u56de\u7b54\uff0c\u8bed\u8a00\u6e05\u6670\u81ea\u7136\u3001\u6613\u4e8e\u7406\u89e3\u3002";
}
~AIChatFormController()
{
GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OnOpenUIFormSuccess);
GameEntry.Event.Unsubscribe(CloseUIFormCompleteEventArgs.EventId, OnCloseUIFormComplete);
UnbindAIChatEvents();
}
private void EnsureAIChatEventBinding()
{
if (_aiChatEventsBound)
{
return;
}
AIChatComponent aiChat = GameEntry.AIChat;
if (aiChat == null)
{
return;
}
aiChat.StreamTextUpdated += OnStreamTextUpdated;
aiChat.StreamRequestCompleted += OnStreamRequestCompleted;
aiChat.StreamRequestFailed += OnStreamRequestFailed;
_aiChatEventsBound = true;
}
private void UnbindAIChatEvents()
{
if (!_aiChatEventsBound)
{
return;
}
AIChatComponent aiChat = GameEntry.AIChat;
if (aiChat == null)
{
_aiChatEventsBound = false;
return;
}
aiChat.StreamTextUpdated -= OnStreamTextUpdated;
aiChat.StreamRequestCompleted -= OnStreamRequestCompleted;
aiChat.StreamRequestFailed -= OnStreamRequestFailed;
_aiChatEventsBound = false;
}
private void TryRefreshUI()
{
if (_context == null)
{
return;
}
if (_aiChatForm == null)
{
_pendingRefresh = true;
return;
}
_aiChatForm.RefreshUI(_context);
_pendingRefresh = false;
}
private void AddMessageToContext(bool isPlayer, string content)
{
if (_context == null)
{
return;
}
_context.Messages ??= new List<AIChatMessageContext>();
_context.Messages.Add(new AIChatMessageContext
{
IsPlayer = isPlayer,
Content = content
});
}
private void PublishErrorMessage(string errorMessage)
{
string content = string.IsNullOrEmpty(errorMessage) ? "Unknown error." : errorMessage;
string formattedContent = "[Error] " + content;
AddMessageToContext(false, formattedContent);
_aiChatForm?.AppendAIDialog(new AIDialogItemContext
{
ParentWidth = GetContentWidth(),
Content = formattedContent
});
_aiChatForm?.SetInputInteractable(true);
_isStreaming = false;
_streamingMessageIndex = -1;
_streamingMessageBuffer.Length = 0;
}
private void EndStreaming(string finalAIContent)
{
string content = string.IsNullOrEmpty(finalAIContent) ? string.Empty : finalAIContent;
if (_streamingMessageIndex >= 0)
{
_aiChatForm?.UpdateAIDialog(_streamingMessageIndex, new AIDialogItemContext
{
ParentWidth = GetContentWidth(),
Content = content
});
}
else if (!string.IsNullOrEmpty(content))
{
_aiChatForm?.AppendAIDialog(new AIDialogItemContext
{
ParentWidth = GetContentWidth(),
Content = content
});
}
if (!string.IsNullOrEmpty(content))
{
AddMessageToContext(false, content);
}
_isStreaming = false;
_streamingMessageIndex = -1;
_streamingMessageBuffer.Length = 0;
_aiChatForm?.SetInputInteractable(true);
_aiChatForm?.FocusInputField();
}
private void OnStreamTextUpdated(string delta)
{
if (!_isStreaming)
{
return;
}
if (string.IsNullOrEmpty(delta))
{
return;
}
_streamingMessageBuffer.Append(delta);
if (_streamingMessageIndex < 0 && _aiChatForm != null)
{
_streamingMessageIndex = _aiChatForm.AppendAIDialog(new AIDialogItemContext
{
ParentWidth = GetContentWidth(),
Content = string.Empty
});
}
if (_streamingMessageIndex >= 0)
{
_aiChatForm?.UpdateAIDialog(_streamingMessageIndex, new AIDialogItemContext
{
ParentWidth = GetContentWidth(),
Content = _streamingMessageBuffer.ToString()
});
}
}
private float GetContentWidth()
{
return _aiChatForm != null ? _aiChatForm.ContentSize.x : 0f;
}
private void OnStreamRequestCompleted(string response)
{
if (!_isStreaming)
{
return;
}
string finalText = string.IsNullOrEmpty(response) ? _streamingMessageBuffer.ToString() : response;
EndStreaming(finalText);
}
private void OnStreamRequestFailed(string errorMessage)
{
if (!_isStreaming)
{
PublishErrorMessage(errorMessage);
return;
}
string partialContent = _streamingMessageBuffer.ToString();
if (string.IsNullOrEmpty(partialContent))
{
EndStreaming("[Error] " + (string.IsNullOrEmpty(errorMessage) ? "Request failed." : errorMessage));
return;
}
EndStreaming(partialContent + "\n\n[Error] " +
(string.IsNullOrEmpty(errorMessage) ? "Request failed." : errorMessage));
}
private void OnOpenUIFormSuccess(object sender, GameEventArgs e)
{
if (!(e is OpenUIFormSuccessEventArgs args))
{
return;
}
if (!_formSerialId.HasValue)
{
return;
}
if (args.UIForm == null || args.UIForm.SerialId != _formSerialId.Value || args.UserData != _context)
{
return;
}
_aiChatForm = args.UIForm.Logic as AIChatForm;
if (_aiChatForm == null)
{
Log.Warning("AIChatFormController open success but form logic is invalid.");
return;
}
if (_pendingRefresh)
{
TryRefreshUI();
}
}
private void OnCloseUIFormComplete(object sender, GameEventArgs e)
{
if (!(e is CloseUIFormCompleteEventArgs args))
{
return;
}
if (args.SerialId != _formSerialId)
{
return;
}
_aiChatForm = null;
_formSerialId = null;
_pendingRefresh = false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7b81d9526357e63489249ce90808e71c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,333 @@
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityGameFramework.Runtime;
namespace UI
{
public class AIChatForm : UGuiForm
{
[SerializeField] private TMP_Text _returnButtonText;
[SerializeField] private TMP_InputField _inputField;
[SerializeField] private RectTransform _dialogContent;
[SerializeField] private TMP_Text _titleText;
[SerializeField] private ScrollRect _historyScrollRect;
[SerializeField] private AIDialogItem _aiDialogItemPrefab;
[SerializeField] private PlayerDialogItem _playerDialogItemPrefab;
[SerializeField] private HorizonSelectGroup _languageSelectGroup;
[SerializeField] private string _returnButtonNormalText = "<sprite name=\"KEYBOARD_Esc\"> Back";
[SerializeField] private string _returnButtonHoverText = "<sprite name=\"KEYBOARD_Esc\"><u> Back </u>";
private readonly List<Component> _dialogItems = new List<Component>();
private AIChatFormController _controller;
private VerticalLayoutGroup _contentLayoutGroup;
public Vector2 ContentSize => _dialogContent != null
? new Vector2(_dialogContent.rect.width, _dialogContent.rect.height)
: Vector2.zero;
protected override void OnOpen(object userData)
{
base.OnOpen(userData);
EnsureReferences();
if (_inputField != null)
{
_inputField.onSubmit.RemoveListener(OnInputFieldSubmitted);
_inputField.onSubmit.AddListener(OnInputFieldSubmitted);
}
if (!(userData is AIChatFormContext context))
{
Log.Error("AIChatFormContext is invalid.");
return;
}
RefreshUI(context);
}
protected override void OnClose(bool isShutdown, object userData)
{
if (_inputField != null)
{
_inputField.onSubmit.RemoveListener(OnInputFieldSubmitted);
}
ClearDialogItems();
_dialogItems.Clear();
_controller = null;
base.OnClose(isShutdown, userData);
}
public void RefreshUI(AIChatFormContext context)
{
EnsureReferences();
if (context == null)
{
return;
}
_controller = context.Controller;
if (_titleText != null && !string.IsNullOrEmpty(context.Title))
{
_titleText.text = context.Title;
}
if (_languageSelectGroup != null)
{
int targetLanguageMode = Mathf.Max(0, context.LanguageMode);
try
{
_languageSelectGroup.SetValue(targetLanguageMode);
}
catch
{
_languageSelectGroup.SetValue(0);
context.LanguageMode = 0;
}
}
if (context.ClearHistoryOnOpen || _dialogItems.Count == 0)
{
ClearDialogItems();
BuildHistory(context.Messages);
}
FocusInputField();
}
public int AppendAIDialog(AIDialogItemContext context)
{
EnsureReferences();
if (_aiDialogItemPrefab == null || _dialogContent == null)
{
Log.Warning("Append AI dialog failed. Missing AI item prefab or content root.");
return -1;
}
AIDialogItem item = Instantiate(_aiDialogItemPrefab, _dialogContent, false);
item.gameObject.SetActive(true);
item.OnInit(context);
_dialogItems.Add(item);
ScrollToBottom();
return _dialogItems.Count - 1;
}
public void UpdateAIDialog(int itemIndex, AIDialogItemContext context)
{
if (itemIndex < 0 || itemIndex >= _dialogItems.Count)
{
return;
}
if (!(_dialogItems[itemIndex] is AIDialogItem item))
{
return;
}
item.OnInit(context);
ScrollToBottom();
}
public int AppendPlayerDialog(PlayerDialogItemContext context)
{
EnsureReferences();
if (_playerDialogItemPrefab == null || _dialogContent == null)
{
Log.Warning("Append player dialog failed. Missing player item prefab or content root.");
return -1;
}
PlayerDialogItem item = Instantiate(_playerDialogItemPrefab, _dialogContent, false);
item.gameObject.SetActive(true);
item.OnInit(context);
_dialogItems.Add(item);
ScrollToBottom();
return _dialogItems.Count - 1;
}
public void SetInputInteractable(bool interactable)
{
if (_inputField == null)
{
return;
}
_inputField.interactable = interactable;
}
public void FocusInputField()
{
if (_inputField == null || !_inputField.interactable)
{
return;
}
_inputField.Select();
_inputField.ActivateInputField();
}
public void OnCommitButtonClick()
{
CommitInput();
}
public void OnReturnButtonHover()
{
if (_returnButtonText != null)
{
_returnButtonText.text = _returnButtonHoverText;
}
}
public void OnReturnButtonClick()
{
_controller?.CloseUI();
}
public void OnReturnButtonHoverEnd()
{
if (_returnButtonText != null)
{
_returnButtonText.text = _returnButtonNormalText;
}
}
private void EnsureReferences()
{
if (_historyScrollRect == null)
{
_historyScrollRect = GetComponentInChildren<ScrollRect>(true);
}
if (_historyScrollRect != null && _dialogContent == null)
{
_dialogContent = _historyScrollRect.content;
}
if (_dialogContent != null)
{
_contentLayoutGroup = _dialogContent.GetComponent<VerticalLayoutGroup>();
if (_contentLayoutGroup == null)
{
_contentLayoutGroup = _dialogContent.gameObject.AddComponent<VerticalLayoutGroup>();
}
_contentLayoutGroup.enabled = true;
_contentLayoutGroup.childAlignment = TextAnchor.UpperLeft;
_contentLayoutGroup.childForceExpandHeight = false;
_contentLayoutGroup.childForceExpandWidth = true;
_contentLayoutGroup.childControlHeight = true;
_contentLayoutGroup.childControlWidth = true;
_contentLayoutGroup.spacing = 12f;
}
}
private void BuildHistory(List<AIChatMessageContext> messages)
{
if (messages == null || messages.Count == 0)
{
return;
}
for (int i = 0; i < messages.Count; i++)
{
AIChatMessageContext message = messages[i];
if (message == null || string.IsNullOrEmpty(message.Content))
{
continue;
}
if (message.IsPlayer)
{
AppendPlayerDialog(new PlayerDialogItemContext
{
Content = message.Content
});
}
else
{
AppendAIDialog(new AIDialogItemContext
{
ParentWidth = ContentSize.x,
Content = message.Content
});
}
}
}
private void CommitInput()
{
if (_controller == null || _inputField == null || !_inputField.interactable)
{
return;
}
string content = _inputField.text;
_inputField.text = string.Empty;
_controller.SubmitPlayerMessage(content, GetLanguageMode());
FocusInputField();
}
private void OnInputFieldSubmitted(string submittedText)
{
CommitInput();
}
private int GetLanguageMode()
{
return _languageSelectGroup != null ? _languageSelectGroup.GetIntValue() : 0;
}
private void ClearDialogItems()
{
if (_dialogContent == null)
{
_dialogItems.Clear();
return;
}
for (int i = _dialogContent.childCount - 1; i >= 0; i--)
{
Transform child = _dialogContent.GetChild(i);
if (child != null)
{
Destroy(child.gameObject);
}
}
_dialogItems.Clear();
}
private void ScrollToBottom()
{
if (_historyScrollRect == null)
{
return;
}
StopCoroutine(nameof(ScrollToBottomAtEndOfFrame));
StartCoroutine(nameof(ScrollToBottomAtEndOfFrame));
}
private IEnumerator ScrollToBottomAtEndOfFrame()
{
yield return null;
Canvas.ForceUpdateCanvases();
_historyScrollRect.verticalNormalizedPosition = 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e59af4c8fe70e184ea5ab8d33fe39c4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,90 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace UI
{
public class AIDialogItem : MonoBehaviour
{
[SerializeField] private RectTransform _rectTransform;
[SerializeField] private TMP_Text _contentText;
private LayoutElement _layoutElement;
private float _minHeight;
private void Awake()
{
CacheReferences();
}
public void OnInit(AIDialogItemContext context)
{
if (context == null)
{
return;
}
OnInit(context.ParentWidth, context.Content);
}
public void OnInit(float parentWidth, string content)
{
CacheReferences();
if (_rectTransform == null || _contentText == null)
{
return;
}
_contentText.text = content ?? string.Empty;
if (parentWidth > 0f)
{
_rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, parentWidth);
}
float preferredHeight = _contentText.GetPreferredValues(_contentText.text, _rectTransform.rect.width,
float.PositiveInfinity).y;
float targetHeight = Mathf.Max(_minHeight, preferredHeight);
if (_layoutElement != null)
{
_layoutElement.preferredHeight = targetHeight;
}
_rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, targetHeight);
}
public void SetContent(string content, float parentWidth)
{
OnInit(parentWidth, content);
}
private void CacheReferences()
{
if (_rectTransform == null)
{
_rectTransform = transform as RectTransform;
}
if (_contentText == null)
{
_contentText = GetComponentInChildren<TMP_Text>(true);
}
if (_layoutElement == null && _rectTransform != null)
{
_layoutElement = gameObject.GetComponent<LayoutElement>();
if (_layoutElement == null)
{
_layoutElement = gameObject.AddComponent<LayoutElement>();
}
}
if (_rectTransform != null && _minHeight <= 0f)
{
_minHeight = Mathf.Max(20f, _rectTransform.rect.height);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e9fe74c6a0acb0a40bae49bdf039be60
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -10,7 +10,6 @@ namespace UI
/// 可拖拽拼装部件:负责拖拽交互、出生点回退与放置到槽位。
/// </summary>
[RequireComponent(typeof(RectTransform))]
[DisallowMultipleComponent]
public class CombineDraggablePart : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerEnterHandler, IPointerExitHandler
{
#region Inspector Config
@@ -4,7 +4,6 @@ using Event;
using GameFramework.Event;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityGameFramework.Runtime;
namespace UI
@@ -12,7 +11,6 @@ namespace UI
/// <summary>
/// MVC UI form for GameplayA. It builds slots and draggable parts from external data.
/// </summary>
[DisallowMultipleComponent]
public class CombineForm : UGuiForm
{
#region Property
@@ -8,7 +8,6 @@ namespace UI
/// <summary>
/// 拼装槽位:接收拖拽部件并转发给玩法控制器做规则校验。
/// </summary>
[DisallowMultipleComponent]
public class CombineSlot : MonoBehaviour, IDropHandler
{
#region Inspector Config
@@ -0,0 +1,114 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace UI
{
public class PlayerDialogItem : MonoBehaviour
{
[SerializeField] private TMP_Text _contentText;
[SerializeField] private Image _bg;
[SerializeField] private float _preferredWidth;
[SerializeField] private float _horizontalPadding;
[SerializeField] private float _verticalPadding;
private RectTransform _rootRectTransform;
private LayoutElement _layoutElement;
private void Awake()
{
CacheReferences();
}
public void OnInit(PlayerDialogItemContext context)
{
if (context == null)
{
return;
}
OnInit(context.Content);
}
public void OnInit(string content)
{
CacheReferences();
if (_contentText == null || _bg == null || _rootRectTransform == null)
{
return;
}
_contentText.text = content ?? string.Empty;
Vector2 unconstrainedSize = _contentText.GetPreferredValues(_contentText.text, float.PositiveInfinity,
float.PositiveInfinity);
float targetWidth = Mathf.Min(_preferredWidth, unconstrainedSize.x + _horizontalPadding);
targetWidth = Mathf.Max(_horizontalPadding, targetWidth);
float textWidth = Mathf.Max(0f, targetWidth - _horizontalPadding);
Vector2 constrainedSize = _contentText.GetPreferredValues(_contentText.text, textWidth,
float.PositiveInfinity);
float targetHeight = Mathf.Max(0f, constrainedSize.y + _verticalPadding);
RectTransform bgRect = _bg.rectTransform;
bgRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, targetWidth);
bgRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, targetHeight);
if (_layoutElement != null)
{
_layoutElement.preferredHeight = targetHeight;
_layoutElement.minHeight = targetHeight;
}
_rootRectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, targetHeight);
}
private void CacheReferences()
{
if (_rootRectTransform == null)
{
_rootRectTransform = transform as RectTransform;
}
if (_contentText == null)
{
_contentText = GetComponentInChildren<TMP_Text>(true);
}
if (_bg == null)
{
_bg = GetComponentInChildren<Image>(true);
}
if (_layoutElement == null && _rootRectTransform != null)
{
_layoutElement = gameObject.GetComponent<LayoutElement>();
if (_layoutElement == null)
{
_layoutElement = gameObject.AddComponent<LayoutElement>();
}
}
if (_contentText == null || _bg == null || _rootRectTransform == null)
{
return;
}
RectTransform bgRect = _bg.rectTransform;
RectTransform textRect = _contentText.rectTransform;
_horizontalPadding = Mathf.Max(0f, bgRect.rect.width - textRect.rect.width);
_verticalPadding = Mathf.Max(0f, bgRect.rect.height - textRect.rect.height);
if (_preferredWidth <= 0f)
{
_preferredWidth = bgRect.rect.width > 0f ? bgRect.rect.width : _rootRectTransform.rect.width;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d82051b50b7801c4f86c71e77c353f77
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -12,6 +12,8 @@ namespace UI
[SerializeField] private float _fadeDuration;
[SerializeField] private bool _allowFade = true;
private Sequence _fadeSequence;
public UnityEvent _onSelect;
@@ -21,17 +23,26 @@ namespace UI
public void OnPointerEnter(PointerEventData eventData)
{
KillFadeSequence();
_fadeSequence = DOTween.Sequence();
_fadeSequence.Append(_bgImage.DOFade(1, _fadeDuration));
if (_allowFade)
{
_fadeSequence = DOTween.Sequence();
_fadeSequence.Append(_bgImage.DOFade(1, _fadeDuration));
}
_onSelect.Invoke();
}
public void OnPointerExit(PointerEventData eventData)
{
KillFadeSequence();
_fadeSequence = DOTween.Sequence();
_fadeSequence.Append(_bgImage.DOFade(0, _fadeDuration));
if (_allowFade)
{
_fadeSequence = DOTween.Sequence();
_fadeSequence.Append(_bgImage.DOFade(0, _fadeDuration));
}
_onDeselect.Invoke();
}