feat: add world chat UI
This commit is contained in:
@@ -24,6 +24,7 @@ namespace XGame.EditorTools
|
||||
// 不能用相对路径(Unity 工作目录是工程目录),需基于 dataPath 解析为绝对路径。
|
||||
private const string JsonRootRelative = "Doc/UIPrefabCreater";
|
||||
private const string TextureRoot = "Assets/Game/Art/UI/Texture";
|
||||
private static readonly int UiLayer = LayerMask.NameToLayer("UI");
|
||||
private static readonly int[] RequiredRefResolution = { 2048, 1024 };
|
||||
|
||||
// 解析 JSON 根目录绝对路径:优先 仓库根/Doc/UIPrefabCreater,回退 工程根/Doc/UIPrefabCreater
|
||||
@@ -49,7 +50,7 @@ namespace XGame.EditorTools
|
||||
// 已识别但本期未实现的组件:遇到只告警、不报错(让 LoginPanelControls.json 仍能通过)
|
||||
private static readonly HashSet<string> KnownUnimplemented = new HashSet<string>
|
||||
{
|
||||
"Toggle", "TMP_InputField", "RotationAnimation"
|
||||
"Toggle", "RotationAnimation"
|
||||
};
|
||||
|
||||
[MenuItem("Tools/UI/Generate Prefab From JSON...")]
|
||||
@@ -258,6 +259,7 @@ namespace XGame.EditorTools
|
||||
string parentName = node["parent"]?.Type == JTokenType.Null ? null : (string)node["parent"];
|
||||
|
||||
GameObject go = new GameObject(name, typeof(RectTransform));
|
||||
ApplyUiLayer(go);
|
||||
map[name] = go;
|
||||
if (parentName == null)
|
||||
{
|
||||
@@ -534,6 +536,21 @@ namespace XGame.EditorTools
|
||||
// onClick 仅作事件标记,不在 Prefab 写死回调
|
||||
break;
|
||||
}
|
||||
case "TMP_InputField":
|
||||
{
|
||||
TMP_InputField input = GetOrAdd<TMP_InputField>(go);
|
||||
Image target = go.GetComponent<Image>();
|
||||
if (target != null) input.targetGraphic = target;
|
||||
if (comp["characterLimit"] != null) input.characterLimit = (int)comp["characterLimit"];
|
||||
input.contentType = ParseInputContentType((string)comp["contentType"]);
|
||||
input.lineType = ParseInputLineType((string)comp["lineType"]);
|
||||
|
||||
string textName = (string)comp["textComponent"];
|
||||
string placeholderName = (string)comp["placeholderComponent"];
|
||||
string placeholderText = (string)comp["placeholder"];
|
||||
deferred.Add(() => BindTmpInputField(go, input, map, textName, placeholderName, placeholderText));
|
||||
break;
|
||||
}
|
||||
case "Shadow":
|
||||
{
|
||||
Shadow shadow = GetOrAdd<Shadow>(go);
|
||||
@@ -630,7 +647,154 @@ namespace XGame.EditorTools
|
||||
}
|
||||
}
|
||||
|
||||
private static void BindTmpInputField(
|
||||
GameObject go,
|
||||
TMP_InputField input,
|
||||
Dictionary<string, GameObject> map,
|
||||
string textName,
|
||||
string placeholderName,
|
||||
string placeholderText)
|
||||
{
|
||||
if (go == null || input == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform textViewport = EnsureInputTextViewport(go);
|
||||
input.textViewport = textViewport;
|
||||
|
||||
TextMeshProUGUI text = ResolveOrCreateInputText(
|
||||
go,
|
||||
map,
|
||||
textName,
|
||||
textViewport,
|
||||
"Text",
|
||||
string.Empty,
|
||||
"#23384CFF");
|
||||
TextMeshProUGUI placeholder = ResolveOrCreateInputText(
|
||||
go,
|
||||
map,
|
||||
placeholderName,
|
||||
textViewport,
|
||||
"Placeholder",
|
||||
placeholderText ?? string.Empty,
|
||||
"#7A8DA1FF");
|
||||
|
||||
input.textComponent = text;
|
||||
input.placeholder = placeholder;
|
||||
if (placeholder != null && !string.IsNullOrEmpty(placeholderText))
|
||||
{
|
||||
placeholder.text = placeholderText;
|
||||
}
|
||||
}
|
||||
|
||||
private static RectTransform EnsureInputTextViewport(GameObject go)
|
||||
{
|
||||
Transform existing = go.transform.Find("Text Area");
|
||||
GameObject textArea = existing == null ? null : existing.gameObject;
|
||||
if (textArea == null)
|
||||
{
|
||||
textArea = new GameObject("Text Area", typeof(RectTransform), typeof(RectMask2D));
|
||||
ApplyUiLayer(textArea);
|
||||
textArea.transform.SetParent(go.transform, false);
|
||||
RectTransform rt = textArea.GetComponent<RectTransform>();
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.offsetMin = new Vector2(24f, 0f);
|
||||
rt.offsetMax = new Vector2(-24f, 0f);
|
||||
}
|
||||
RectTransform viewport = textArea.GetComponent<RectTransform>();
|
||||
if (textArea.GetComponent<RectMask2D>() == null)
|
||||
{
|
||||
textArea.AddComponent<RectMask2D>();
|
||||
}
|
||||
return viewport;
|
||||
}
|
||||
|
||||
private static TextMeshProUGUI ResolveOrCreateInputText(
|
||||
GameObject inputRoot,
|
||||
Dictionary<string, GameObject> map,
|
||||
string nodeName,
|
||||
RectTransform viewport,
|
||||
string fallbackName,
|
||||
string defaultText,
|
||||
string defaultColor)
|
||||
{
|
||||
TextMeshProUGUI text = null;
|
||||
if (!string.IsNullOrEmpty(nodeName) && map.TryGetValue(nodeName, out GameObject mapped) && mapped != null)
|
||||
{
|
||||
text = mapped.GetComponent<TextMeshProUGUI>();
|
||||
if (text == null)
|
||||
{
|
||||
text = mapped.AddComponent<TextMeshProUGUI>();
|
||||
}
|
||||
if (mapped.transform.parent != viewport)
|
||||
{
|
||||
mapped.transform.SetParent(viewport, false);
|
||||
}
|
||||
StretchToFill(mapped.GetComponent<RectTransform>());
|
||||
}
|
||||
|
||||
if (text == null)
|
||||
{
|
||||
Transform existing = viewport.Find(fallbackName);
|
||||
GameObject textGo = existing == null ? null : existing.gameObject;
|
||||
if (textGo == null)
|
||||
{
|
||||
textGo = new GameObject(fallbackName, typeof(RectTransform));
|
||||
ApplyUiLayer(textGo);
|
||||
textGo.transform.SetParent(viewport, false);
|
||||
}
|
||||
StretchToFill(textGo.GetComponent<RectTransform>());
|
||||
text = GetOrAdd<TextMeshProUGUI>(textGo);
|
||||
text.text = defaultText ?? string.Empty;
|
||||
}
|
||||
|
||||
ConfigureInputText(text, defaultColor);
|
||||
return text;
|
||||
}
|
||||
|
||||
private static void ConfigureInputText(TextMeshProUGUI text, string color)
|
||||
{
|
||||
if (text == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (text.fontSize <= 0f)
|
||||
{
|
||||
text.fontSize = 28f;
|
||||
}
|
||||
text.alignment = text.alignment == TextAlignmentOptions.TopLeft ? TextAlignmentOptions.Left : text.alignment;
|
||||
text.raycastTarget = false;
|
||||
if (!string.IsNullOrEmpty(color))
|
||||
{
|
||||
text.color = ParseColor(color, text.color);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 资源/解析辅助 ----------
|
||||
private static void StretchToFill(RectTransform rt)
|
||||
{
|
||||
if (rt == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
private static void ApplyUiLayer(GameObject go)
|
||||
{
|
||||
if (go != null && UiLayer >= 0)
|
||||
{
|
||||
go.layer = UiLayer;
|
||||
}
|
||||
}
|
||||
|
||||
private static Type FindComponentType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
@@ -739,6 +903,36 @@ namespace XGame.EditorTools
|
||||
}
|
||||
}
|
||||
|
||||
private static TMP_InputField.ContentType ParseInputContentType(string contentType)
|
||||
{
|
||||
switch (contentType)
|
||||
{
|
||||
case "IntegerNumber": return TMP_InputField.ContentType.IntegerNumber;
|
||||
case "DecimalNumber": return TMP_InputField.ContentType.DecimalNumber;
|
||||
case "Alphanumeric": return TMP_InputField.ContentType.Alphanumeric;
|
||||
case "Name": return TMP_InputField.ContentType.Name;
|
||||
case "EmailAddress": return TMP_InputField.ContentType.EmailAddress;
|
||||
case "Password": return TMP_InputField.ContentType.Password;
|
||||
case "Pin": return TMP_InputField.ContentType.Pin;
|
||||
case "Custom": return TMP_InputField.ContentType.Custom;
|
||||
case "Standard":
|
||||
default:
|
||||
return TMP_InputField.ContentType.Standard;
|
||||
}
|
||||
}
|
||||
|
||||
private static TMP_InputField.LineType ParseInputLineType(string lineType)
|
||||
{
|
||||
switch (lineType)
|
||||
{
|
||||
case "MultiLineSubmit": return TMP_InputField.LineType.MultiLineSubmit;
|
||||
case "MultiLineNewline": return TMP_InputField.LineType.MultiLineNewline;
|
||||
case "SingleLine":
|
||||
default:
|
||||
return TMP_InputField.LineType.SingleLine;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumStrings(JToken token)
|
||||
{
|
||||
if (token is JArray a)
|
||||
|
||||
@@ -151,7 +151,7 @@ public class XWorldUtil
|
||||
{
|
||||
if (EditorPrefs.HasKey(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY))
|
||||
return EditorPrefs.GetBool(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY, true);
|
||||
return EditorPrefs.GetBool(AUTO_CONVERT_HTML_ON_FOCUS_PREF_KEY, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool s_IsAutoConvertingJsonPrefabs;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using XGame.EditorTools;
|
||||
|
||||
public sealed class JsonUIPrefabBuilderInputFieldTests
|
||||
{
|
||||
private const string JsonPath = "../Temp/UIGenTests/UI_TMPInputTest.json";
|
||||
private const string PrefabPath = "Assets/Temp/UIGenTests/UI_TMPInputTest.prefab";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
AssetDatabase.DeleteAsset(PrefabPath);
|
||||
AssetDatabase.DeleteAsset("Assets/Temp/UIGenTests");
|
||||
string fullJsonPath = Path.GetFullPath(Path.Combine(Application.dataPath, JsonPath));
|
||||
if (File.Exists(fullJsonPath))
|
||||
{
|
||||
File.Delete(fullJsonPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildFromJsonFile_CreatesAndBindsTmpInputField()
|
||||
{
|
||||
string fullJsonPath = WriteJson();
|
||||
|
||||
string outputPath = JsonUIPrefabBuilder.BuildFromJsonFile(fullJsonPath);
|
||||
|
||||
Assert.That(outputPath, Is.EqualTo(PrefabPath));
|
||||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
|
||||
Transform inputTransform = prefab.transform.Find("InputHitArea");
|
||||
Assert.That(inputTransform, Is.Not.Null);
|
||||
TMP_InputField input = inputTransform.GetComponent<TMP_InputField>();
|
||||
Assert.That(input, Is.Not.Null);
|
||||
Assert.That(inputTransform.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI")));
|
||||
Assert.That(input.textComponent, Is.Not.Null);
|
||||
Assert.That(input.textComponent.name, Is.EqualTo("Txt_InputValue"));
|
||||
Assert.That(input.placeholder, Is.Not.Null);
|
||||
Assert.That(input.placeholder.name, Is.EqualTo("Txt_InputPlaceholder"));
|
||||
Assert.That(input.characterLimit, Is.EqualTo(140));
|
||||
Assert.That(input.lineType, Is.EqualTo(TMP_InputField.LineType.SingleLine));
|
||||
}
|
||||
|
||||
private static string WriteJson()
|
||||
{
|
||||
string fullJsonPath = Path.GetFullPath(Path.Combine(Application.dataPath, JsonPath));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullJsonPath));
|
||||
File.WriteAllText(fullJsonPath, @"{
|
||||
""schemaVersion"": 1,
|
||||
""prefabName"": ""UI_TMPInputTest"",
|
||||
""prefabPath"": ""Assets/Temp/UIGenTests/UI_TMPInputTest.prefab"",
|
||||
""description"": ""TMP input field test."",
|
||||
""canvas"": {
|
||||
""canvasScaler"": {
|
||||
""referenceResolution"": [2048, 1024],
|
||||
""matchWidthOrHeight"": 0.5
|
||||
}
|
||||
},
|
||||
""assets"": {
|
||||
""sprites"": {}
|
||||
},
|
||||
""nodes"": [
|
||||
{
|
||||
""name"": ""UI_TMPInputTest"",
|
||||
""parent"": null,
|
||||
""active"": true,
|
||||
""rect"": {
|
||||
""anchorMin"": [0, 0],
|
||||
""anchorMax"": [1, 1],
|
||||
""offsetMin"": [0, 0],
|
||||
""offsetMax"": [0, 0]
|
||||
},
|
||||
""components"": []
|
||||
},
|
||||
{
|
||||
""name"": ""InputHitArea"",
|
||||
""parent"": ""UI_TMPInputTest"",
|
||||
""active"": true,
|
||||
""rect"": {
|
||||
""anchorMin"": [0.5, 0.5],
|
||||
""anchorMax"": [0.5, 0.5],
|
||||
""pivot"": [0.5, 0.5],
|
||||
""anchoredPosition"": [0, 0],
|
||||
""sizeDelta"": [520, 80]
|
||||
},
|
||||
""components"": [
|
||||
{
|
||||
""type"": ""Image"",
|
||||
""imageType"": ""Sliced"",
|
||||
""raycastTarget"": true,
|
||||
""color"": ""#FFFFFFFF""
|
||||
},
|
||||
{
|
||||
""type"": ""TMP_InputField"",
|
||||
""textComponent"": ""Txt_InputValue"",
|
||||
""placeholderComponent"": ""Txt_InputPlaceholder"",
|
||||
""placeholder"": ""输入内容..."",
|
||||
""characterLimit"": 140,
|
||||
""lineType"": ""SingleLine"",
|
||||
""contentType"": ""Standard""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
""name"": ""Txt_InputPlaceholder"",
|
||||
""parent"": ""InputHitArea"",
|
||||
""active"": true,
|
||||
""rect"": {
|
||||
""anchorMin"": [0, 0],
|
||||
""anchorMax"": [1, 1],
|
||||
""offsetMin"": [24, 0],
|
||||
""offsetMax"": [-24, 0]
|
||||
},
|
||||
""components"": [
|
||||
{
|
||||
""type"": ""TextMeshProUGUI"",
|
||||
""text"": ""输入内容..."",
|
||||
""fontSize"": 28,
|
||||
""alignment"": ""Left"",
|
||||
""color"": ""#7A8DA1FF"",
|
||||
""raycastTarget"": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
""name"": ""Txt_InputValue"",
|
||||
""parent"": ""InputHitArea"",
|
||||
""active"": true,
|
||||
""rect"": {
|
||||
""anchorMin"": [0, 0],
|
||||
""anchorMax"": [1, 1],
|
||||
""offsetMin"": [24, 0],
|
||||
""offsetMax"": [-24, 0]
|
||||
},
|
||||
""components"": [
|
||||
{
|
||||
""type"": ""TextMeshProUGUI"",
|
||||
""text"": """",
|
||||
""fontSize"": 28,
|
||||
""alignment"": ""Left"",
|
||||
""color"": ""#23384CFF"",
|
||||
""raycastTarget"": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
""bindings"": [],
|
||||
""events"": []
|
||||
}");
|
||||
return fullJsonPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 203834191d3c4f7c8b175a00e72186c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
using XGame;
|
||||
|
||||
public sealed class UIManagerAttachTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
DestroyIfExists("ZeroSizedCanvas");
|
||||
DestroyIfExists("UIRoot");
|
||||
EventSystem eventSystem = Object.FindObjectOfType<EventSystem>();
|
||||
if (eventSystem != null)
|
||||
{
|
||||
Object.DestroyImmediate(eventSystem.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachTo_StretchesZeroSizedCanvasRootAndAppliesUiLayer()
|
||||
{
|
||||
GameObject ui = new GameObject("ZeroSizedCanvas", typeof(RectTransform), typeof(Canvas));
|
||||
GameObject child = new GameObject("Child", typeof(RectTransform), typeof(Image));
|
||||
child.transform.SetParent(ui.transform, false);
|
||||
|
||||
RectTransform rt = ui.GetComponent<RectTransform>();
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.zero;
|
||||
rt.sizeDelta = Vector2.zero;
|
||||
|
||||
UIManager.AttachTo(UILayer.Normal, ui);
|
||||
|
||||
int uiLayer = LayerMask.NameToLayer("UI");
|
||||
Assert.That(rt.anchorMin, Is.EqualTo(Vector2.zero));
|
||||
Assert.That(rt.anchorMax, Is.EqualTo(Vector2.one));
|
||||
Assert.That(rt.offsetMin, Is.EqualTo(Vector2.zero));
|
||||
Assert.That(rt.offsetMax, Is.EqualTo(Vector2.zero));
|
||||
Assert.That(ui.layer, Is.EqualTo(uiLayer));
|
||||
Assert.That(child.layer, Is.EqualTo(uiLayer));
|
||||
}
|
||||
|
||||
private static void DestroyIfExists(string name)
|
||||
{
|
||||
GameObject go = GameObject.Find(name);
|
||||
if (go != null)
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 793775c4067b48ffad9e0ca52d8e92db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "XWorld.Link.EditModeTests",
|
||||
"rootNamespace": "",
|
||||
"references": ["XWorld.Link"],
|
||||
"references": [
|
||||
"XWorld.Link",
|
||||
"Unity.TextMeshPro",
|
||||
"GUID:119d45324677fde49923efc4ea54b313"
|
||||
],
|
||||
"includePlatforms": ["Editor"],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
|
||||
public sealed class XWorldJsonPrefabAutoConvertTests
|
||||
{
|
||||
private const string JsonPrefKey = "XWorld.UI.AutoConvertJsonOnFocus";
|
||||
private const string HtmlPrefKey = "XWorld.UI.AutoConvertHtmlOnFocus";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
EditorPrefs.DeleteKey(JsonPrefKey);
|
||||
EditorPrefs.DeleteKey(HtmlPrefKey);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonAutoConvertDefaultsToEnabledIndependentlyOfHtmlPreference()
|
||||
{
|
||||
EditorPrefs.DeleteKey(JsonPrefKey);
|
||||
EditorPrefs.SetBool(HtmlPrefKey, false);
|
||||
|
||||
Assert.That(ReadJsonAutoConvertEnabled(), Is.True);
|
||||
}
|
||||
|
||||
private static bool ReadJsonAutoConvertEnabled()
|
||||
{
|
||||
MethodInfo method = typeof(XWorldUtil).GetMethod(
|
||||
"IsAutoConvertJsonOnFocusEnabled",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(method, Is.Not.Null);
|
||||
return (bool)method.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 802e633c1f3d4d38be7f72851fed177f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user