feat: add world chat UI

This commit is contained in:
ud18010
2026-07-28 11:54:07 +08:00
parent 09c89aadf0
commit 48aed26725
28 changed files with 5308 additions and 99 deletions
@@ -38,6 +38,7 @@ namespace XGame
private MiniGameNetChannel lobbyNet;
private MiniGameHost gameHost;
private LobbyWorldController lobbyWorld;
private WorldChatModule worldChat;
private readonly List<GameInfoMsg> games = new List<GameInfoMsg>();
private bool lobbyActive;
private bool openingLogin;
@@ -120,6 +121,7 @@ namespace XGame
{
lobbyActive = false;
lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null;
socket?.close();
socket = null;
@@ -581,6 +583,9 @@ namespace XGame
}
lobbyWorld?.HandleFrame(frame);
break;
case FrameworkOpcode.WorldChatMessage:
worldChat?.HandleWorldChatMessage(WorldChatMessageMsg.Decode(frame.Payload));
break;
}
}
@@ -590,6 +595,7 @@ namespace XGame
pendingGameVersion = 0;
lobbyActive = false;
lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null;
CloseWaiting();
CloseLobby();
@@ -668,6 +674,48 @@ namespace XGame
GetLobbyPlayerId(),
GetLobbyPlayerName(),
0);
EnsureWorldChatModule();
}
private void EnsureWorldChatModule()
{
if (lobbyNet == null)
{
return;
}
if (worldChat == null)
{
worldChat = gameObject.GetComponent<WorldChatModule>();
if (worldChat == null)
{
worldChat = gameObject.AddComponent<WorldChatModule>();
}
}
worldChat.Initialize(GetLobbyPlayerId(), SendWorldChat);
if (!worldChat.IsOpen)
{
XWCoroutine.StartCo(worldChat.OpenAsync());
}
}
private void SendWorldChat(string text)
{
text = (text ?? string.Empty).Trim();
if (text.Length == 0 || lobbyNet == null)
{
return;
}
lobbyNet.SendFramework(
FrameworkOpcode.WorldChatSend,
new WorldChatSendMsg { Text = text }.Encode());
}
private void CloseWorldChat()
{
if (worldChat != null)
{
worldChat.Close();
}
}
private static string GetLobbyPlayerName()
@@ -908,15 +908,14 @@ namespace XGame
joystickRoot.gameObject.SetActive(joined);
yield break;
}
// 摇杆是抬头 HUD → 挂到 UIRoot/HUD 层(见 UI 挂载规则);Main 未就绪时降级到本地 HUD 画布
Transform parent = GetHudParent();
GameObject prefabObj = prefab as GameObject;
if (prefabObj == null)
{
Debug.LogError("[LobbyWorldController] load joystick prefab failed: " + JoystickPrefabPath);
yield break;
}
rootObj = Instantiate(prefabObj, parent, false);
rootObj = Instantiate(prefabObj);
UIManager.AttachTo(UILayer.HUD, rootObj);
rootObj.name = "UI_LobbyJoystick";
joystickRoot = rootObj.GetComponent<RectTransform>();
@@ -27,6 +27,7 @@ namespace XGame
private const string UIRootName = "UIRoot";
private static readonly string[] LayerNames = { "HUD", "Normal", "Dialog", "Tips" };
private static readonly Vector2 DesignResolution = new Vector2(2048f, 1024f);
private static readonly int UiLayerId = LayerMask.NameToLayer("UI");
/// <summary>把 UI 挂到指定层(worldPositionStays=false 保留本地布局/锚点)。</summary>
public static void AttachTo(UILayer layer, GameObject ui, bool worldPositionStays = false)
@@ -39,6 +40,8 @@ namespace XGame
if (layerRt != null)
{
ui.transform.SetParent(layerRt, worldPositionStays);
ApplyUiLayer(ui.transform);
EnsureVisibleAttachedRoot(ui);
}
}
@@ -62,6 +65,10 @@ namespace XGame
if (go == null)
{
go = new GameObject(UIRootName, typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
if (UiLayerId >= 0)
{
go.layer = UiLayerId;
}
Canvas canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 100; // 位于主界面之上
@@ -78,9 +85,45 @@ namespace XGame
CreateFullStretchChild(go.transform, LayerNames[i]);
}
}
ApplyUiLayer(go.transform);
return go.transform;
}
private static void EnsureVisibleAttachedRoot(GameObject ui)
{
RectTransform rt = ui == null ? null : ui.transform as RectTransform;
if (rt == null || ui.GetComponent<Canvas>() == null)
{
return;
}
if (Mathf.Abs(rt.rect.width) > 0.01f || Mathf.Abs(rt.rect.height) > 0.01f)
{
return;
}
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.localScale = Vector3.one;
}
private static void ApplyUiLayer(Transform root)
{
if (root == null || UiLayerId < 0)
{
return;
}
root.gameObject.layer = UiLayerId;
for (int i = 0; i < root.childCount; i++)
{
ApplyUiLayer(root.GetChild(i));
}
}
private static void EnsureEventSystem()
{
if (EventSystem.current != null || Object.FindObjectOfType<EventSystem>() != null)
@@ -93,6 +136,10 @@ namespace XGame
private static Transform CreateFullStretchChild(Transform parent, string name)
{
GameObject go = new GameObject(name, typeof(RectTransform));
if (UiLayerId >= 0)
{
go.layer = UiLayerId;
}
RectTransform rt = go.GetComponent<RectTransform>();
rt.SetParent(parent, false);
rt.anchorMin = Vector2.zero;
@@ -0,0 +1,383 @@
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XWorld.Framework.Protocol;
using UObject = UnityEngine.Object;
namespace XGame
{
public sealed class WorldChatModule : MonoBehaviour
{
private const string ChatPrefabPath = "Assets/Game/Art/UI/Prefab/UI_CommonChat.prefab";
private const int MaxMessages = 80;
private const int MaxInputLength = 200;
private GameObject view;
private GameObject panelCollapsed;
private GameObject panelChat;
private Transform contentMessages;
private GameObject otherTemplate;
private GameObject selfTemplate;
private TMP_InputField inputField;
private TMP_Text inputPlaceholder;
private TMP_Text inputValue;
private ScrollRect scrollMessages;
private bool opening;
private int openVersion;
private int localPlayerId;
private readonly Queue<GameObject> messageRows = new Queue<GameObject>();
public Action<string> OnSendWorldChat;
public bool IsOpen => view != null;
public void Initialize(int playerId, Action<string> sendWorldChat)
{
localPlayerId = playerId;
OnSendWorldChat = sendWorldChat;
}
public IEnumerator OpenAsync()
{
if (view != null || opening)
{
yield break;
}
opening = true;
int version = ++openVersion;
UObject loaded = null;
yield return XResLoader.coLoadRes(ChatPrefabPath, typeof(GameObject), obj => loaded = obj);
opening = false;
if (version != openVersion)
{
yield break;
}
GameObject prefab = loaded as GameObject;
if (prefab == null)
{
Debug.LogError("[WorldChatModule] load chat prefab failed: " + ChatPrefabPath);
yield break;
}
view = Instantiate(prefab);
UIManager.AttachTo(UILayer.Normal, view);
CacheControls();
BindEvents();
Collapse();
}
public void Close()
{
openVersion++;
if (view != null)
{
Destroy(view);
view = null;
}
messageRows.Clear();
panelCollapsed = null;
panelChat = null;
contentMessages = null;
otherTemplate = null;
selfTemplate = null;
inputField = null;
inputPlaceholder = null;
inputValue = null;
scrollMessages = null;
opening = false;
}
public void HandleWorldChatMessage(WorldChatMessageMsg message)
{
if (message == null)
{
return;
}
if (view == null)
{
XWCoroutine.StartCo(OpenThenAppend(message));
return;
}
AppendMessage(message);
}
private IEnumerator OpenThenAppend(WorldChatMessageMsg message)
{
yield return OpenAsync();
AppendMessage(message);
}
private void CacheControls()
{
if (view == null)
{
return;
}
panelCollapsed = FindGameObject("Panel_Collapsed");
panelChat = FindGameObject("Panel_Chat");
contentMessages = FindTransform("Content_Messages");
otherTemplate = FindGameObject("Item_MessageOther_Template");
selfTemplate = FindGameObject("Item_MessageSelf_Template");
scrollMessages = FindComponent<ScrollRect>("Scroll_Messages");
inputPlaceholder = FindComponent<TMP_Text>("Txt_InputPlaceholder");
inputValue = FindComponent<TMP_Text>("Txt_InputValue");
EnsureInputField();
}
private void BindEvents()
{
BindButton("Btn_CollapsedInput", Expand);
BindButton("Btn_Close", Collapse);
BindButton("Btn_Send", SendCurrentInput);
BindButton("Btn_ClearInput", ClearInput);
BindButton("InputHitArea", FocusInput);
if (inputField != null)
{
inputField.onSubmit.RemoveListener(OnInputSubmitted);
inputField.onSubmit.AddListener(OnInputSubmitted);
}
}
private void EnsureInputField()
{
Transform hitArea = FindTransform("InputHitArea");
if (hitArea == null)
{
return;
}
inputField = hitArea.GetComponent<TMP_InputField>();
if (inputField == null)
{
inputField = hitArea.gameObject.AddComponent<TMP_InputField>();
}
Button button = hitArea.GetComponent<Button>();
if (button != null)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(FocusInput);
}
TextMeshProUGUI textComponent = EnsureInputText(hitArea, "Txt_InputValue", ref inputValue, string.Empty, "#23384CFF");
TextMeshProUGUI placeholderComponent = EnsureInputText(hitArea, "Txt_InputPlaceholder", ref inputPlaceholder, "输入聊天内容...", "#7A8DA1FF");
if (textComponent == null)
{
Debug.LogError("[WorldChatModule] cannot create input text component.");
inputField = null;
return;
}
inputField.textComponent = textComponent;
inputField.placeholder = placeholderComponent;
inputField.targetGraphic = hitArea.GetComponent<Image>();
inputField.characterLimit = MaxInputLength;
inputField.lineType = TMP_InputField.LineType.SingleLine;
inputField.contentType = TMP_InputField.ContentType.Standard;
inputField.onValueChanged.RemoveListener(OnInputValueChanged);
inputField.onValueChanged.AddListener(OnInputValueChanged);
OnInputValueChanged(inputField.text);
}
private TextMeshProUGUI EnsureInputText(Transform parent, string nodeName, ref TMP_Text cached, string defaultText, string color)
{
bool created = false;
TextMeshProUGUI text = cached as TextMeshProUGUI;
if (text == null)
{
Transform existing = FindTransform(nodeName);
text = existing == null ? null : existing.GetComponent<TextMeshProUGUI>();
}
if (text == null)
{
GameObject go = new GameObject(nodeName, typeof(RectTransform), typeof(TextMeshProUGUI));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(76f, 0f);
rt.offsetMax = new Vector2(-72f, 0f);
text = go.GetComponent<TextMeshProUGUI>();
created = true;
text.fontSize = 28f;
text.alignment = TextAlignmentOptions.Left;
text.raycastTarget = false;
if (ColorUtility.TryParseHtmlString(color, out Color parsed))
{
text.color = parsed;
}
}
if (created)
{
text.text = defaultText ?? string.Empty;
}
cached = text;
return text;
}
private void Expand()
{
if (panelCollapsed != null) panelCollapsed.SetActive(false);
if (panelChat != null) panelChat.SetActive(true);
FocusInput();
}
private void Collapse()
{
if (panelCollapsed != null) panelCollapsed.SetActive(true);
if (panelChat != null) panelChat.SetActive(false);
}
private void FocusInput()
{
if (inputField == null)
{
return;
}
inputField.Select();
inputField.ActivateInputField();
}
private void ClearInput()
{
if (inputField != null)
{
inputField.text = string.Empty;
}
}
private void OnInputSubmitted(string text)
{
SendCurrentInput();
}
private void SendCurrentInput()
{
string text = inputField == null ? string.Empty : inputField.text;
text = (text ?? string.Empty).Trim();
if (text.Length == 0)
{
return;
}
if (text.Length > MaxInputLength)
{
text = text.Substring(0, MaxInputLength);
}
OnSendWorldChat?.Invoke(text);
ClearInput();
FocusInput();
}
private void OnInputValueChanged(string text)
{
if (inputPlaceholder != null)
{
inputPlaceholder.gameObject.SetActive(string.IsNullOrEmpty(text));
}
}
private void AppendMessage(WorldChatMessageMsg message)
{
CacheControls();
if (contentMessages == null)
{
return;
}
bool isSelf = message.PlayerId == localPlayerId;
GameObject template = isSelf ? selfTemplate : otherTemplate;
if (template == null)
{
return;
}
GameObject row = Instantiate(template, contentMessages);
row.name = isSelf ? "Item_MessageSelf" : "Item_MessageOther";
row.SetActive(true);
if (isSelf)
{
SetChildText(row.transform, "Txt_SelfMessage", message.Text);
}
else
{
SetChildText(row.transform, "Txt_OtherName", string.IsNullOrEmpty(message.PlayerName) ? "玩家" : message.PlayerName);
SetChildText(row.transform, "Txt_OtherMessage", message.Text);
}
messageRows.Enqueue(row);
while (messageRows.Count > MaxMessages)
{
GameObject old = messageRows.Dequeue();
if (old != null)
{
Destroy(old);
}
}
if (scrollMessages != null)
{
StartCoroutine(ScrollToBottomNextFrame());
}
}
private IEnumerator ScrollToBottomNextFrame()
{
yield return null;
Canvas.ForceUpdateCanvases();
if (scrollMessages != null)
{
scrollMessages.verticalNormalizedPosition = 0f;
}
}
private void BindButton(string nodeName, UnityEngine.Events.UnityAction action)
{
Button button = FindComponent<Button>(nodeName);
if (button == null)
{
return;
}
button.onClick.RemoveAllListeners();
button.onClick.AddListener(action);
}
private GameObject FindGameObject(string nodeName)
{
Transform t = FindTransform(nodeName);
return t == null ? null : t.gameObject;
}
private Transform FindTransform(string nodeName)
{
return view == null ? null : view.transform.FindTransform(nodeName);
}
private T FindComponent<T>(string nodeName) where T : Component
{
Transform t = FindTransform(nodeName);
return t == null ? null : t.GetComponent<T>();
}
private static void SetChildText(Transform root, string childName, string text)
{
Transform t = root.FindTransform(childName);
if (t == null)
{
return;
}
TMP_Text label = t.GetComponent<TMP_Text>();
if (label != null)
{
label.text = text ?? string.Empty;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 285864a87a0741fab43dbb1ee0fc4da7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: