Compare commits

..
3 Commits
Author SHA1 Message Date
ud18010 053a95b4fb fix: polish lobby chat and joystick 2026-07-28 17:23:11 +08:00
ud18010 48aed26725 feat: add world chat UI 2026-07-28 11:54:07 +08:00
ud18010 09c89aadf0 docs: add humanoid animation import plan 2026-07-27 19:39:14 +08:00
34 changed files with 5640 additions and 101 deletions
@@ -0,0 +1,168 @@
# UI 生成提示词(AI JSON 生成契约)
> 可直接作为系统提示词投喂给 AI。完整流程、排错和维护说明见 `Client/Assets/Doc/Rule/UIGenerationRules.md`。
你是本项目的 Unity UI 原型生成器。你的**唯一产物是一份 JSON**,写入 `Doc/UIPrefabCreater/{界面名}.json`,由 Unity 编辑器自动转换为 UGUI Prefab。不要输出 HTML、C#、Lua、Markdown 说明或浏览器 CSS。JSON 必须符合 `JsonUIPrefabBuilder` 当前支持的字段和组件能力。
## 1. 强制上下文
生成前必须遵守以下项目文档:
- `Client/Assets/Doc/Rule/UnityProject.md`Unity 工程目录、JSON→Prefab 管线、UI 挂载、资源加载和小游戏规则。
- `Doc/design/UI_Design_Principles.md`:休闲卡通糖果风、配色、字体、控件视觉规范。
- `Doc/design/UI_Pic_Set.md`:通用 UI 切图、Sprite 名、九宫格 Border 与用途。
- `Doc/UIPrefabCreater/UI_LobbyJoystick.json``Doc/UIPrefabCreater/UI_MiniGameResult.json`JSON 写法参考。
## 2. 唯一产物
只输出一个 JSON 根对象,必须包含:
- `schemaVersion`:当前使用 `1`
- `prefabName`Prefab 名,必须与根节点 `name` 完全一致。
- `prefabPath`:必须位于 Unity `Assets/` 下。通用 UI 使用 `Assets/Game/Art/UI/Prefab/{prefabName}.prefab`;小游戏专属 UI 使用对应小游戏目录,不要写入通用 `Game` 目录。
- `description`:说明界面用途、挂载层级、运行时绑定方式和特殊适配原因。
- `canvas.canvasScaler.referenceResolution`:必须为 `[2048, 1024]`
- `assets.sprites`:本 JSON 中所有 Image/Button 使用的 Sprite 短名到资源路径的映射。
- `nodes`:所有需要生成 Unity GameObject 的节点。
- `bindings`:业务代码需要按名访问的节点名数组,可为空数组。
- `events`:点击等事件声明数组,可为空数组。
## 3. JSON 骨架
```json
{
"schemaVersion": 1,
"prefabName": "UI_Example",
"prefabPath": "Assets/Game/Art/UI/Prefab/UI_Example.prefab",
"description": "Normal 层普通功能界面。运行时控制器按 bindings/events 绑定节点。",
"canvas": {
"canvasScaler": {
"referenceResolution": [2048, 1024],
"matchWidthOrHeight": 0.5
}
},
"assets": {
"sprites": {
"ui_panel_main": "Assets/Game/Art/UI/Texture/Common/ui_panel_main.png",
"btn_primary_normal": "Assets/Game/Art/UI/Texture/Common/btn_primary_normal.png",
"btn_primary_hover": "Assets/Game/Art/UI/Texture/Common/btn_primary_hover.png",
"btn_primary_pressed": "Assets/Game/Art/UI/Texture/Common/btn_primary_pressed.png",
"btn_primary_disabled": "Assets/Game/Art/UI/Texture/Common/btn_primary_disabled.png"
}
},
"nodes": [],
"bindings": [],
"events": []
}
```
## 4. 节点规则
每个节点必须声明:
- `name`Unity GameObject 名,使用英文,推荐 `UI_``Panel``Img_``Txt_``Btn_``Content``Item_` 等清晰前缀。
- `parent`:父节点名;根节点为 `null`。父节点必须在 `nodes` 数组中先于子节点出现。
- `active`:是否默认激活。
- `rect`RectTransform 参数。
- `components`:组件数组,可为空数组。
`rect` 优先使用:
- 常规固定位置:`anchorMin``anchorMax``pivot``anchoredPosition``sizeDelta`
- 全屏或拉伸区域:`anchorMin``anchorMax``offsetMin``offsetMax`
同一父节点下,`nodes` 数组越靠后的节点显示在越上层。
## 5. 当前转换器支持的组件
`Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs` 当前代码为准,可直接生成的组件包括:
- `Canvas`
- `CanvasScaler`
- `GraphicRaycaster`
- `CanvasGroup`
- `Image`
- `TextMeshProUGUI`
- `Button`
- `Shadow`
- `Outline`
- `ScrollRect`
- `RectMask2D`
- `Mask`
- `TMP_InputField`
- `VerticalLayoutGroup`
- `ContentSizeFitter`
- `LayoutElement`
- `MonoBehaviour`
以下组件在项目规则中允许声明,但当前转换器尚未完整实现或会跳过,生成时不要依赖它们直接可用:
- `Toggle`
- `TMP_Dropdown`
- `Slider`
- `Scrollbar`
- `ScrollView`
如果需求必须使用这些控件,可以先用 `Image``TextMeshProUGUI``Button``ScrollRect` 等生成可视骨架,并在 `description` 说明需要人工或后续转换器补齐。
## 6. 视觉与素材规则
- 风格必须符合休闲、友好、明快的卡通糖果风:大圆角、深色描边、顶部高光、柔和阴影、高饱和按钮。
- 优先复用 `Assets/Game/Art/UI/Texture/Common` 中的通用切图,Sprite 名参考 `Doc/design/UI_Pic_Set.md`
- 所有可拉伸容器、按钮、输入底、列表项、进度底等必须使用 `"imageType": "Sliced"`
- 普通图标、关闭按钮、返回按钮等使用 `"imageType": "Simple"`
- 文字统一使用 `TextMeshProUGUI`,字体遵循项目默认思源黑体。
- 浅色文字在按钮、复杂背景、深色或高饱和背景上必须使用 `Outline``Shadow`,颜色优先 `#102033``#0B1724`,不要默认纯黑。
- 间距使用 8 的倍数,内容区留白通常不小于 24,安全区不小于 24。
- 按钮优先使用 `Image + Button``Button.transition` 使用 `SpriteSwap`,并提供 normal/hover/pressed/disabled 状态图。
- 需要新贴图时,先在 `description` 标明缺图;实际贴图用 gptimage2 按项目风格生成到对应目录。小游戏专属贴图必须生成到小游戏自己的资源目录结构。
## 7. 事件与绑定
- 业务代码需要访问的节点必须加入 `bindings`,例如 `["Txt_Title", "Btn_Start"]`
- 可点击按钮组件中声明 `onClick` 字符串,并在根对象 `events` 中登记:
```json
"events": [
{ "name": "Example.Start", "node": "Btn_Start" }
]
```
`onClick` 只是事件标记,不会在 Prefab 中写死 Unity 回调。运行时 C# 控制器按事件名和节点名绑定。
需要挂载脚本时,在根节点或业务节点上声明:
```json
{
"type": "MonoBehaviour",
"typeName": "XGame.ExampleController"
}
```
## 8. 层级与挂载意图
`description` 中明确界面所属层级:
- `HUD`:常驻战斗/大厅信息,如血条、摇杆、小地图。
- `Normal`:普通功能面板,如背包、设置、商城。
- `Dialog`:模态弹窗、二次确认。
- `Tips`Toast、飘字、轻提示。
Prefab 生成后运行时必须挂到 `UIRoot` 下对应层级,不要直接挂到 `UIRoot`
## 9. 输出前自检
输出 JSON 前逐条检查:
- [ ] 只输出 JSON,不输出解释文本。
- [ ] `prefabName` 与唯一根节点 `name` 一致。
- [ ] 只有一个 `parent: null` 的根节点。
- [ ] `prefabPath``Assets/` 下,且目录符合通用/小游戏资源规则。
- [ ] `referenceResolution``[2048, 1024]`
- [ ] 每个节点有 `name``parent``active``rect``components`
- [ ] 父节点先于子节点出现,所有父节点名真实存在。
- [ ] 所有 `Image``Button` 使用的 Sprite 都已登记到 `assets.sprites`
- [ ] 九宫格控件声明 `"imageType": "Sliced"`
- [ ] 文字使用 `TextMeshProUGUI`,关键浅色字有轻描边或阴影。
- [ ] 事件节点存在于 `nodes`,绑定节点存在于 `nodes`
- [ ] 不依赖当前转换器未实现的组件完成核心功能。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7aa6827799244d9ba13792d52a13f7e9
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+297
View File
@@ -0,0 +1,297 @@
# UI 生成规则(AI JSON 生成流程 + 人类参考)
> 本文档面向“AI 根据需求生成 JSON → Unity 自动转换为 Prefab → C# 接管业务”的本项目 UI 自动生成流程。
>
> - **Part A**:整体流水线。
> - **Part B**AI 生成契约。
> - **Part C**:生成后接管与排错。
> - **Part D**:后续扩展建议。
>
> 可直接投喂 AI 的精简系统提示词见 `Client/Assets/Doc/Rule/UIGenerationPrompt.md`。
---
## Part A - 整体流水线
本项目采用 JSON-first 流程,不引入参考项目的 HTML 中间层:
```text
需求说明
-> AI 读取项目规则/设计规范/切图清单/现有 JSON 示例
-> 生成 Doc/UIPrefabCreater/{UIName}.json
-> Unity 重新获得焦点或手动菜单触发
-> JsonUIPrefabBuilder 生成 Assets/.../{UIName}.prefab
-> C# MonoBehaviour/业务代码按节点名、bindings、events 接管
```
### A1. 为什么不使用 HTML
参考项目的核心经验是“AI 产物契约 + 自动转换触发 + 自检清单”,不是 HTML 格式本身。本项目已经有 `Doc/UIPrefabCreater/*.json` 到 Prefab 的转换器,直接让 AI 输出 JSON 能减少一层转换误差,并且更贴近 Unity 的 RectTransform、组件、SpriteSwap 和 bindings/events。
### A2. 自动转换入口
转换器相关代码:
- `Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs`
- `Client/Assets/Script/Editor/XWorldUtil.cs`
可用入口:
- Unity 菜单 `Tools/UI/Generate Prefab From JSON...`
- Unity 菜单 `Tools/UI/Generate All Prefabs From JSON`
- Unity 菜单 `XWorld/UI/Convert Changed JSON Prefabs Now`
- Unity 菜单 `XWorld/UI/Auto Convert JSON On Focus`
自动转换开启后,Unity 重新获得焦点时会扫描 `Doc/UIPrefabCreater`,只转换比目标 Prefab 更新的 UI JSON。
### A3. 目录约定
JSON 输入:
- `Doc/UIPrefabCreater/{UIName}.json`
通用 UI 产物:
- `Assets/Game/Art/UI/Prefab/{UIName}.prefab`
- `Assets/Game/Art/UI/Texture/Common/{sprite}.png`
- `Assets/Game/Art/UI/Font/SourceHanSansCN-Regular.otf`
小游戏专属 UI 产物:
-`UnityProject.md` 规则,将通用资源目录中的 `Game` 替换为小游戏名。
- 小游戏专属贴图不要生成到通用 `Assets/Game/...` 目录。
---
## Part B - AI 生成契约
### B1. 输入信息模板
给 AI 生成 UI 时,建议提供以下信息:
```text
界面名:
所属层级:HUD / Normal / Dialog / Tips
使用场景:
目标用户操作:
需要显示的数据:
需要点击/拖拽/输入的控件:
业务绑定节点:
事件名:
是否小游戏专属:
是否需要新贴图:
特殊适配要求:
```
### B2. 必读上下文
AI 生成前必须参考:
- `Client/Assets/Doc/Rule/UnityProject.md`
- `Doc/design/UI_Design_Principles.md`
- `Doc/design/UI_Pic_Set.md`
- `Doc/UIPrefabCreater/UI_LobbyJoystick.json`
- `Doc/UIPrefabCreater/UI_MiniGameResult.json`
- `Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs` 的当前支持能力
### B3. JSON 顶层字段
| 字段 | 要求 |
| --- | --- |
| `schemaVersion` | 当前为 `1` |
| `prefabName` | 与根节点 `name` 完全一致 |
| `prefabPath` | 必须位于 `Assets/` 下 |
| `description` | 描述用途、层级、运行时绑定、特殊适配 |
| `canvas` | 必须包含 `canvasScaler.referenceResolution: [2048,1024]` |
| `assets` | 统一登记 Sprite 短名和路径 |
| `nodes` | 每个 Unity 节点一条记录 |
| `bindings` | C# 需要按名访问的节点 |
| `events` | 事件名与触发节点 |
### B4. 节点命名
节点名必须英文,便于 C# `transform.Find` 或递归查找:
| 类型 | 推荐命名 |
| --- | --- |
| 根节点 | `UI_Xxx` |
| 面板 | `Panel` / `Panel_Main` / `Panel_Content` |
| 图片 | `Img_Mask` / `Img_Bg` / `Img_Icon` |
| 文本 | `Txt_Title` / `Txt_Count` / `Txt_Desc` |
| 按钮 | `Btn_Start` / `Btn_Close` / `Btn_Ok` |
| 列表 | `Scroll_List` / `Viewport` / `Content` / `Item_Template` |
| 业务区域 | `Group_Top` / `Group_Info` / `Group_Actions` |
### B5. 当前可直接生成的组件
`JsonUIPrefabBuilder` 当前可直接创建:
- `Canvas`
- `CanvasScaler`
- `GraphicRaycaster`
- `CanvasGroup`
- `Image`
- `TextMeshProUGUI`
- `Button`
- `Shadow`
- `Outline`
- `ScrollRect`
- `RectMask2D`
- `Mask`
- `TMP_InputField`
- `VerticalLayoutGroup`
- `ContentSizeFitter`
- `LayoutElement`
- `MonoBehaviour`
项目规则文档中出现但当前转换器尚未完整实现的组件,要谨慎使用:
- `Toggle`
- `TMP_Dropdown`
- `Slider`
- `Scrollbar`
- `ScrollView`
其中 `Toggle` 当前会被识别为已知但未实现组件并跳过;`TMP_InputField` 已支持生成与绑定;`ScrollView` 应改用当前代码支持的 `ScrollRect` 结构。
### B6. 常用结构模板
#### 普通面板
- 根节点:可按需要挂 `Canvas``CanvasScaler``GraphicRaycaster`,或作为由外部 Canvas 承载的子树。
- 背景遮罩:Dialog 层使用 `Img_Mask`,全屏拉伸,Sprite `ui_overlay_dim`
- 主面板:`Panel` 使用 `ui_panel_main``ui_panel_popup``imageType: Sliced`
- 标题:`Txt_Title`,字号 36-52,根据界面层级调整。
- 内容区:`ui_panel_content`,留白不小于 24。
- 操作区:按钮组放在底部,主行为用绿色 `btn_primary_*`
#### 按钮
按钮节点通常同时包含 `Image``Button`
```json
{
"type": "Image",
"sprite": "btn_primary_normal",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_primary_hover",
"spritePressed": "btn_primary_pressed",
"spriteDisabled": "btn_primary_disabled",
"onClick": "Example.Confirm"
}
```
按钮文字作为按钮子节点,使用 `TextMeshProUGUI`。浅色按钮文字必须加轻量 `Outline``Shadow`
#### 滚动列表
当前使用 `ScrollRect`,推荐结构:
```text
Scroll_List (Image + ScrollRect)
Viewport (Image + RectMask2D)
Content (VerticalLayoutGroup + ContentSizeFitter)
Item_Template (Image + LayoutElement, active=false)
```
`ScrollRect` 组件通过 `viewport``content` 字段引用节点名。
#### HUD
HUD 节点应尽量少用模态遮罩。角落信息使用对应角的锚点;摇杆、血条、状态条等优先使用拉伸或角锚,避免写死只适配一个屏幕比例。
---
## Part C - 生成后接管与排错
### C1. 生成后接管步骤
1. 打开 Unity,确认 JSON 自动转换是否开启,或手动执行 `XWorld/UI/Convert Changed JSON Prefabs Now`
2. 检查 Console 中 `[UIGen]` 日志,确认没有错误,警告均可解释。
3. 打开生成 Prefab,核对层级、RectTransform、Sprite、文字、按钮状态。
4. 根节点或业务节点按需挂 `MonoBehaviour` 控制器,或让 JSON 中的 `MonoBehaviour.typeName` 自动添加。
5. C# 通过 `transform.Find`、递归查找或 `Utility.FindTransform` 获取 `TextMeshProUGUI``Button``Image``RectTransform` 等组件。
6.`bindings``events` 绑定数据刷新与点击回调。
7. 运行时资源加载必须使用异步接口,不要新写同步资源加载。
### C2. 常见问题
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| JSON 不转换 | 自动转换关闭、Unity 未重新获得焦点、JSON 不比 Prefab 新 | 手动执行 `Convert Changed JSON Prefabs Now` |
| `referenceResolution 必须为 [2048, 1024]` | 画布分辨率错误 | 改为 `[2048,1024]`,特殊情况写入 `description` 并同步调整转换器规则 |
| `根节点数量必须为 1` | 多个 `parent:null` | 只保留一个根节点 |
| `prefabName 必须与根节点名一致` | 顶层命名不一致 | 统一 `prefabName` 与根节点 `name` |
| `找不到 Sprite` | `assets.sprites` 短名或路径与真实切图不一致 | 按 `UI_Pic_Set.md` 校正 Sprite 名,确认图片导入为 Sprite |
| 图片存在但非 Sprite | Texture Import 设置错误 | 设置 `Texture Type = Sprite (2D and UI)` |
| Toggle 没生成 | 当前转换器未实现 | 用可视骨架替代,或补转换器 |
| TMP_InputField 文本不显示 | `textComponent` / `placeholderComponent` 指向的节点缺少 `TextMeshProUGUI` 或不在输入框下 | 修正节点名,或省略节点名让转换器自动创建 `Text Area/Text/Placeholder` |
| ScrollView 警告未知 | 当前组件名应为 `ScrollRect` | 改为 `ScrollRect`,并声明 `viewport``content` |
| 文字不可读 | 缺描边/阴影或背景对比不足 | 加 `Outline`/`Shadow`,使用 `#102033``#0B1724` |
### C3. 提交前自检
- [ ] JSON 位于 `Doc/UIPrefabCreater`
- [ ] 顶层字段完整。
- [ ] `prefabName` 与根节点名一致。
- [ ] 只有一个根节点。
- [ ] 节点名英文且不重复。
- [ ] 父节点先于子节点出现。
- [ ] `referenceResolution``[2048,1024]`
- [ ] 所有 Sprite 已登记且路径符合通用/小游戏目录规则。
- [ ] 九宫格图片使用 `Sliced`
- [ ] 文字统一 TMP,关键浅色字有轻描边或阴影。
- [ ] 按钮有 normal/hover/pressed/disabled 状态。
- [ ] `bindings``events` 中的节点真实存在。
- [ ] 不依赖未实现组件完成核心功能。
- [ ] 生成的 Prefab 在 Unity 中打开无明显重叠、越界或丢图。
---
## Part D - 后续扩展建议
### D1. 补齐转换器组件
建议优先补齐以下组件,顺序按 UI 高频程度排列:
1. `Toggle`
2. `Slider`
3. `Scrollbar`
4. `TMP_Dropdown`
每补一个组件,都应同步更新:
- `Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs`
- `Client/Assets/Doc/Rule/UIGenerationPrompt.md`
- `Client/Assets/Doc/Rule/UIGenerationRules.md`
- 对应 EditMode 测试或最小 JSON 样例
### D2. 公共控件识别
参考项目的公共控件自动识别可以作为本项目下一阶段能力,但需要先定义稳定的本项目 canonical Sprite 名和空间约束。可优先考虑:
| 目标 | 识别依据 | 生成结果 |
| --- | --- | --- |
| 通用关闭按钮 | `btn_close` | 替换或规范化为关闭按钮 Prefab |
| 通用返回按钮 | `btn_back` | 替换或规范化为返回按钮 Prefab |
| 标准弹窗 | `ui_overlay_dim` + `ui_panel_popup` + `btn_close` | 标准 Dialog Prefab |
| 资源条 | `ico_coin` / `ico_diamond` + 数值 + `ico_plus` | 标准 Currency 组件 |
| Toast | `toast_bg` + 文本 | 标准 Tips 组件 |
识别器必须保守:缺件、歧义、跨区域时保留原节点并输出 Warning,不要误合并。
### D3. AI 生成质量评审
AI 产出的 UI JSON 应优先检查三件事:
1. **工程可转换**:字段、父子关系、组件名、Sprite 名都能被当前工具处理。
2. **视觉一致**:符合糖果风、配色语义、九宫格、TMP 和可读性规则。
3. **业务可接管**:节点命名稳定,bindings/events 完整,C# 能按名找到控件。
只要这三件事成立,生成的 Prefab 就可以作为程序接管的稳定 UI 骨架。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b2cd5daa93154de08df4414baf54b7f5
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+7 -1
View File
@@ -1,5 +1,11 @@
# Unity 项目规则 # Unity 项目规则
## Unity 编辑器环境
- Unity 版本:`2022.3.62f3`(见 `Client/ProjectSettings/ProjectVersion.txt`)。
- 本机 Unity Editor 目录:`D:\Program Files\Unity 2022.3.62f3\Editor`
- Unity 可执行文件:`D:\Program Files\Unity 2022.3.62f3\Editor\Unity.exe`
## 美术资源目录 ## 美术资源目录
通用资源目录: 通用资源目录:
- Shader 目录:`Client\Assets\Game\shader` - Shader 目录:`Client\Assets\Game\shader`
@@ -68,7 +74,7 @@ UI 预设统一挂在 `UIRoot` 节点下对应的子节点,按界面类型分
- 标题、按钮文字、状态提示、倒计时等关键 UI 文本可使用轻描边或阴影;普通正文、列表、输入框文本不默认加描边,优先通过背景对比度保证可读性。 - 标题、按钮文字、状态提示、倒计时等关键 UI 文本可使用轻描边或阴影;普通正文、列表、输入框文本不默认加描边,优先通过背景对比度保证可读性。
- TextMeshProUGUI 描边建议保持轻量,常规 UI 使用较小 Outline Width,避免粗黑边导致界面变脏。 - TextMeshProUGUI 描边建议保持轻量,常规 UI 使用较小 Outline Width,避免粗黑边导致界面变脏。
- 按钮节点使用 `Button` 组件,`transition` 优先使用 `SpriteSwap`;需要点击事件时在组件中声明 `onClick`,并在根对象 `events` 中登记事件名和节点名。 - 按钮节点使用 `Button` 组件,`transition` 优先使用 `SpriteSwap`;需要点击事件时在组件中声明 `onClick`,并在根对象 `events` 中登记事件名和节点名。
- 输入框节点使用 `TMP_InputField`,必须声明 `placeholder``characterLimit``contentType`;密码输入使用 `"contentType": "Password"` - 输入框节点使用 `TMP_InputField`,必须声明 `placeholder``characterLimit``contentType`可声明 `textComponent``placeholderComponent` 绑定已有 `TextMeshProUGUI` 节点,不声明时转换器自动创建 `Text Area/Text/Placeholder` 标准结构;密码输入使用 `"contentType": "Password"`
- Toggle 节点必须声明 `toggleGroup``isOn`,并分别用子节点声明背景和选中标记。 - Toggle 节点必须声明 `toggleGroup``isOn`,并分别用子节点声明背景和选中标记。
- 需要业务绑定的控件必须加入根对象 `bindings`,例如 `login.username``register.password`;事件统一加入根对象 `events`,例如 `{ "name": "Auth.Login", "node": "Btn_Login" }` - 需要业务绑定的控件必须加入根对象 `bindings`,例如 `login.username``register.password`;事件统一加入根对象 `events`,例如 `{ "name": "Auth.Login", "node": "Btn_Login" }`
- 需要挂载运行时脚本时使用 `MonoBehaviour` 组件,并声明 `typeName`,例如 `"typeName": "XGame.MatchWaitingController"` - 需要挂载运行时脚本时使用 `MonoBehaviour` 组件,并声明 `typeName`,例如 `"typeName": "XGame.MatchWaitingController"`
@@ -21,6 +21,8 @@ namespace XWorld.Framework.Protocol
LobbyState = 13, LobbyState = 13,
LobbyMove = 14, LobbyMove = 14,
LobbyLeave = 15, LobbyLeave = 15,
WorldChatSend = 16,
WorldChatMessage = 17,
} }
public sealed class HeartbeatMsg public sealed class HeartbeatMsg
@@ -381,4 +383,52 @@ namespace XWorld.Framework.Protocol
}; };
} }
} }
public sealed class WorldChatSendMsg
{
public string Text;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(Text);
return w.ToArray();
}
public static WorldChatSendMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new WorldChatSendMsg { Text = r.ReadString() };
}
}
public sealed class WorldChatMessageMsg
{
public int PlayerId;
public string PlayerName;
public string Text;
public long ServerTimeMs;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(PlayerId);
w.WriteString(PlayerName);
w.WriteString(Text);
w.WriteVarLong(ServerTimeMs);
return w.ToArray();
}
public static WorldChatMessageMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new WorldChatMessageMsg
{
PlayerId = r.ReadVarInt(),
PlayerName = r.ReadString(),
Text = r.ReadString(),
ServerTimeMs = r.ReadVarLong(),
};
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3df6794ba6e8fe04eaeca41a8f2f0d80
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -9,9 +9,7 @@ GameObject:
serializedVersion: 6 serializedVersion: 6
m_Component: m_Component:
- component: {fileID: 2000000000000000001} - component: {fileID: 2000000000000000001}
- component: {fileID: 1019881374409531711} - component: {fileID: 8720693099983328153}
- component: {fileID: -3354548917243692638}
- component: {fileID: 5674143642735492565}
m_Layer: 5 m_Layer: 5
m_Name: UI_LobbyJoystick m_Name: UI_LobbyJoystick
m_TagString: Untagged m_TagString: Untagged
@@ -31,62 +29,28 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: m_Children:
- {fileID: 8967095311611333637} - {fileID: 2000000000000000002}
- {fileID: 2000000000000000003}
- {fileID: 2000000000000000004}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0} m_AnchoredPosition: {x: 260, y: 220}
m_SizeDelta: {x: 0, y: 0} m_SizeDelta: {x: 148, y: 148}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1019881374409531711 --- !u!225 &8720693099983328153
CanvasRenderer: CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000001}
m_CullTransparentMesh: 1
--- !u!223 &-3354548917243692638
Canvas:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0} m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000001} m_GameObject: {fileID: 1000000000000000001}
m_Enabled: 1 m_Enabled: 1
serializedVersion: 3 m_Alpha: 0.96
m_RenderMode: 2 m_Interactable: 0
m_Camera: {fileID: 0} m_BlocksRaycasts: 0
m_PlaneDistance: 100 m_IgnoreParentGroups: 0
m_PixelPerfect: 0
m_ReceivesEvents: 1
m_OverrideSorting: 0
m_OverridePixelPerfect: 0
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 0
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
m_SortingOrder: 0
m_TargetDisplay: 0
--- !u!114 &5674143642735492565
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreReversedGraphics: 1
m_BlockingObjects: 0
m_BlockingMask:
serializedVersion: 2
m_Bits: 4294967295
--- !u!1 &1000000000000000002 --- !u!1 &1000000000000000002
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -113,12 +77,12 @@ RectTransform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000002} m_GameObject: {fileID: 1000000000000000002}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 8967095311611333637} m_Father: {fileID: 2000000000000000001}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5}
@@ -203,12 +167,12 @@ RectTransform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000003} m_GameObject: {fileID: 1000000000000000003}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 8967095311611333637} m_Father: {fileID: 2000000000000000001}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5}
@@ -279,12 +243,12 @@ RectTransform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1000000000000000004} m_GameObject: {fileID: 1000000000000000004}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 8967095311611333637} m_Father: {fileID: 2000000000000000001}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5}
@@ -344,41 +308,3 @@ MonoBehaviour:
m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.36078432} m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.36078432}
m_EffectDistance: {x: 0, y: -5} m_EffectDistance: {x: 0, y: -5}
m_UseGraphicAlpha: 1 m_UseGraphicAlpha: 1
--- !u!1 &2264943395517929137
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8967095311611333637}
m_Layer: 5
m_Name: Joystick
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &8967095311611333637
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2264943395517929137}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2000000000000000002}
- {fileID: 2000000000000000003}
- {fileID: 2000000000000000004}
m_Father: {fileID: 2000000000000000001}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 200, y: 150}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
@@ -24,6 +24,7 @@ namespace XGame.EditorTools
// 不能用相对路径(Unity 工作目录是工程目录),需基于 dataPath 解析为绝对路径。 // 不能用相对路径(Unity 工作目录是工程目录),需基于 dataPath 解析为绝对路径。
private const string JsonRootRelative = "Doc/UIPrefabCreater"; private const string JsonRootRelative = "Doc/UIPrefabCreater";
private const string TextureRoot = "Assets/Game/Art/UI/Texture"; private const string TextureRoot = "Assets/Game/Art/UI/Texture";
private static readonly int UiLayer = LayerMask.NameToLayer("UI");
private static readonly int[] RequiredRefResolution = { 2048, 1024 }; private static readonly int[] RequiredRefResolution = { 2048, 1024 };
// 解析 JSON 根目录绝对路径:优先 仓库根/Doc/UIPrefabCreater,回退 工程根/Doc/UIPrefabCreater // 解析 JSON 根目录绝对路径:优先 仓库根/Doc/UIPrefabCreater,回退 工程根/Doc/UIPrefabCreater
@@ -49,7 +50,7 @@ namespace XGame.EditorTools
// 已识别但本期未实现的组件:遇到只告警、不报错(让 LoginPanelControls.json 仍能通过) // 已识别但本期未实现的组件:遇到只告警、不报错(让 LoginPanelControls.json 仍能通过)
private static readonly HashSet<string> KnownUnimplemented = new HashSet<string> private static readonly HashSet<string> KnownUnimplemented = new HashSet<string>
{ {
"Toggle", "TMP_InputField", "RotationAnimation" "Toggle", "RotationAnimation"
}; };
[MenuItem("Tools/UI/Generate Prefab From JSON...")] [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"]; string parentName = node["parent"]?.Type == JTokenType.Null ? null : (string)node["parent"];
GameObject go = new GameObject(name, typeof(RectTransform)); GameObject go = new GameObject(name, typeof(RectTransform));
ApplyUiLayer(go);
map[name] = go; map[name] = go;
if (parentName == null) if (parentName == null)
{ {
@@ -534,6 +536,21 @@ namespace XGame.EditorTools
// onClick 仅作事件标记,不在 Prefab 写死回调 // onClick 仅作事件标记,不在 Prefab 写死回调
break; 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": case "Shadow":
{ {
Shadow shadow = GetOrAdd<Shadow>(go); 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) private static Type FindComponentType(string typeName)
{ {
if (string.IsNullOrEmpty(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) private static IEnumerable<string> EnumStrings(JToken token)
{ {
if (token is JArray a) if (token is JArray a)
+1 -1
View File
@@ -151,7 +151,7 @@ public class XWorldUtil
{ {
if (EditorPrefs.HasKey(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY)) 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_JSON_ON_FOCUS_PREF_KEY, true);
return EditorPrefs.GetBool(AUTO_CONVERT_HTML_ON_FOCUS_PREF_KEY, true); return true;
} }
static bool s_IsAutoConvertingJsonPrefabs; 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:
@@ -0,0 +1,88 @@
using System;
using System.Reflection;
using NUnit.Framework;
using TMPro;
using UnityEditor;
using UnityEngine;
using XGame;
using XWorld.Framework.Protocol;
public sealed class WorldChatModuleTests
{
private GameObject view;
private GameObject host;
[TearDown]
public void TearDown()
{
if (view != null)
{
UnityEngine.Object.DestroyImmediate(view);
}
if (host != null)
{
UnityEngine.Object.DestroyImmediate(host);
}
}
[Test]
public void EndEditingInput_SubmitsTrimmedWorldChat()
{
WorldChatModule module = CreateBoundModule();
TMP_InputField input = view.transform.FindTransform("InputHitArea").GetComponent<TMP_InputField>();
string sent = null;
module.OnSendWorldChat = text => sent = text;
input.text = " hello world ";
input.onEndEdit.Invoke(input.text);
Assert.That(sent, Is.EqualTo("hello world"));
Assert.That(input.text, Is.Empty);
}
[Test]
public void ReceivedMessage_UpdatesCollapsedPreview()
{
WorldChatModule module = CreateBoundModule();
TMP_Text collapsedText = view.transform.FindTransform("Txt_CollapsedPlaceholder").GetComponent<TMP_Text>();
module.HandleWorldChatMessage(new WorldChatMessageMsg
{
PlayerId = 2,
PlayerName = "Bob",
Text = "hello world"
});
Assert.That(collapsedText.text, Is.EqualTo("Bob: hello world"));
}
private WorldChatModule CreateBoundModule()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Game/Art/UI/Prefab/UI_CommonChat.prefab");
Assert.That(prefab, Is.Not.Null);
view = UnityEngine.Object.Instantiate(prefab);
host = new GameObject("WorldChatModuleTestHost");
WorldChatModule module = host.AddComponent<WorldChatModule>();
module.Initialize(1, _ => { });
SetPrivateField(module, "view", view);
InvokePrivate(module, "CacheControls");
InvokePrivate(module, "BindEvents");
return module;
}
private static void SetPrivateField(object target, string name, object value)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(target, value);
}
private static void InvokePrivate(object target, string name)
{
MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(target, Array.Empty<object>());
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f953f6437b2d4314b8c8c77d531223e0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,7 +1,12 @@
{ {
"name": "XWorld.Link.EditModeTests", "name": "XWorld.Link.EditModeTests",
"rootNamespace": "", "rootNamespace": "",
"references": ["XWorld.Link"], "references": [
"XWorld.Link",
"XWorld.Framework.Shared",
"Unity.TextMeshPro",
"GUID:119d45324677fde49923efc4ea54b313"
],
"includePlatforms": ["Editor"], "includePlatforms": ["Editor"],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false, "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 MiniGameNetChannel lobbyNet;
private MiniGameHost gameHost; private MiniGameHost gameHost;
private LobbyWorldController lobbyWorld; private LobbyWorldController lobbyWorld;
private WorldChatModule worldChat;
private readonly List<GameInfoMsg> games = new List<GameInfoMsg>(); private readonly List<GameInfoMsg> games = new List<GameInfoMsg>();
private bool lobbyActive; private bool lobbyActive;
private bool openingLogin; private bool openingLogin;
@@ -120,6 +121,7 @@ namespace XGame
{ {
lobbyActive = false; lobbyActive = false;
lobbyWorld?.SendLeave(); lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null; lobbyNet = null;
socket?.close(); socket?.close();
socket = null; socket = null;
@@ -581,6 +583,10 @@ namespace XGame
} }
lobbyWorld?.HandleFrame(frame); lobbyWorld?.HandleFrame(frame);
break; break;
case FrameworkOpcode.WorldChatMessage:
Debug.Log("[CSharpClientApp] recv WorldChatMessage bytes=" + (frame.Payload?.Length ?? 0));
worldChat?.HandleWorldChatMessage(WorldChatMessageMsg.Decode(frame.Payload));
break;
} }
} }
@@ -590,6 +596,7 @@ namespace XGame
pendingGameVersion = 0; pendingGameVersion = 0;
lobbyActive = false; lobbyActive = false;
lobbyWorld?.SendLeave(); lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null; lobbyNet = null;
CloseWaiting(); CloseWaiting();
CloseLobby(); CloseLobby();
@@ -668,6 +675,60 @@ namespace XGame
GetLobbyPlayerId(), GetLobbyPlayerId(),
GetLobbyPlayerName(), GetLobbyPlayerName(),
0); 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)
{
return;
}
if (lobbyNet == null)
{
Debug.LogWarning("[CSharpClientApp] drop WorldChatSend: lobbyNet is null");
return;
}
if (!lobbyNet.IsConnected)
{
Debug.LogWarning("[CSharpClientApp] drop WorldChatSend: lobbyNet is disconnected");
return;
}
byte[] payload = new WorldChatSendMsg { Text = text }.Encode();
Debug.Log("[CSharpClientApp] send WorldChatSend bytes=" + payload.Length);
lobbyNet.SendFramework(
FrameworkOpcode.WorldChatSend,
payload);
}
private void CloseWorldChat()
{
if (worldChat != null)
{
worldChat.Close();
}
} }
private static string GetLobbyPlayerName() private static string GetLobbyPlayerName()
@@ -40,7 +40,7 @@ namespace XGame
private const float DirectionChangeDot = 0.98f; private const float DirectionChangeDot = 0.98f;
private const float JoystickRadius = 74f; private const float JoystickRadius = 74f;
private const float JoystickKnobRadius = 28f; private const float JoystickKnobRadius = 28f;
private const float JoystickPadding = 34f; private const float JoystickPadding = 100.0f;
private const float JoystickDeadZone = 0.18f; private const float JoystickDeadZone = 0.18f;
private const float JoystickAcquireRadiusMultiplier = 1.6f; private const float JoystickAcquireRadiusMultiplier = 1.6f;
private const float CameraMinDistance = 3.5f; private const float CameraMinDistance = 3.5f;
@@ -908,15 +908,14 @@ namespace XGame
joystickRoot.gameObject.SetActive(joined); joystickRoot.gameObject.SetActive(joined);
yield break; yield break;
} }
// 摇杆是抬头 HUD → 挂到 UIRoot/HUD 层(见 UI 挂载规则);Main 未就绪时降级到本地 HUD 画布
Transform parent = GetHudParent();
GameObject prefabObj = prefab as GameObject; GameObject prefabObj = prefab as GameObject;
if (prefabObj == null) if (prefabObj == null)
{ {
Debug.LogError("[LobbyWorldController] load joystick prefab failed: " + JoystickPrefabPath); Debug.LogError("[LobbyWorldController] load joystick prefab failed: " + JoystickPrefabPath);
yield break; yield break;
} }
rootObj = Instantiate(prefabObj, parent, false); rootObj = Instantiate(prefabObj);
UIManager.AttachTo(UILayer.HUD, rootObj);
rootObj.name = "UI_LobbyJoystick"; rootObj.name = "UI_LobbyJoystick";
joystickRoot = rootObj.GetComponent<RectTransform>(); joystickRoot = rootObj.GetComponent<RectTransform>();
@@ -27,6 +27,7 @@ namespace XGame
private const string UIRootName = "UIRoot"; private const string UIRootName = "UIRoot";
private static readonly string[] LayerNames = { "HUD", "Normal", "Dialog", "Tips" }; private static readonly string[] LayerNames = { "HUD", "Normal", "Dialog", "Tips" };
private static readonly Vector2 DesignResolution = new Vector2(2048f, 1024f); private static readonly Vector2 DesignResolution = new Vector2(2048f, 1024f);
private static readonly int UiLayerId = LayerMask.NameToLayer("UI");
/// <summary>把 UI 挂到指定层(worldPositionStays=false 保留本地布局/锚点)。</summary> /// <summary>把 UI 挂到指定层(worldPositionStays=false 保留本地布局/锚点)。</summary>
public static void AttachTo(UILayer layer, GameObject ui, bool worldPositionStays = false) public static void AttachTo(UILayer layer, GameObject ui, bool worldPositionStays = false)
@@ -39,6 +40,8 @@ namespace XGame
if (layerRt != null) if (layerRt != null)
{ {
ui.transform.SetParent(layerRt, worldPositionStays); ui.transform.SetParent(layerRt, worldPositionStays);
ApplyUiLayer(ui.transform);
EnsureVisibleAttachedRoot(ui);
} }
} }
@@ -62,6 +65,10 @@ namespace XGame
if (go == null) if (go == null)
{ {
go = new GameObject(UIRootName, typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); go = new GameObject(UIRootName, typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
if (UiLayerId >= 0)
{
go.layer = UiLayerId;
}
Canvas canvas = go.GetComponent<Canvas>(); Canvas canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 100; // 位于主界面之上 canvas.sortingOrder = 100; // 位于主界面之上
@@ -78,9 +85,45 @@ namespace XGame
CreateFullStretchChild(go.transform, LayerNames[i]); CreateFullStretchChild(go.transform, LayerNames[i]);
} }
} }
ApplyUiLayer(go.transform);
return 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() private static void EnsureEventSystem()
{ {
if (EventSystem.current != null || Object.FindObjectOfType<EventSystem>() != null) if (EventSystem.current != null || Object.FindObjectOfType<EventSystem>() != null)
@@ -93,6 +136,10 @@ namespace XGame
private static Transform CreateFullStretchChild(Transform parent, string name) private static Transform CreateFullStretchChild(Transform parent, string name)
{ {
GameObject go = new GameObject(name, typeof(RectTransform)); GameObject go = new GameObject(name, typeof(RectTransform));
if (UiLayerId >= 0)
{
go.layer = UiLayerId;
}
RectTransform rt = go.GetComponent<RectTransform>(); RectTransform rt = go.GetComponent<RectTransform>();
rt.SetParent(parent, false); rt.SetParent(parent, false);
rt.anchorMin = Vector2.zero; rt.anchorMin = Vector2.zero;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6508ec0f8203b6d4e868b5a74194a926
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,425 @@
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 TMP_Text collapsedPreviewText;
private ScrollRect scrollMessages;
private bool opening;
private int openVersion;
private int localPlayerId;
private string collapsedDefaultText;
private string lastCollapsedPreview;
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;
collapsedPreviewText = null;
scrollMessages = null;
collapsedDefaultText = null;
lastCollapsedPreview = 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");
collapsedPreviewText = FindComponent<TMP_Text>("Txt_CollapsedPlaceholder");
if (collapsedPreviewText != null && string.IsNullOrEmpty(collapsedDefaultText))
{
collapsedDefaultText = collapsedPreviewText.text;
}
RefreshCollapsedPreview();
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);
inputField.onEndEdit.RemoveListener(OnInputEndEdit);
inputField.onEndEdit.AddListener(OnInputEndEdit);
}
}
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 OnInputEndEdit(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);
}
Debug.Log("[WorldChatModule] send world chat length=" + text.Length);
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());
}
SetCollapsedPreview(message, isSelf);
}
private void SetCollapsedPreview(WorldChatMessageMsg message, bool isSelf)
{
string name = isSelf ? "\u6211" : message.PlayerName;
if (string.IsNullOrWhiteSpace(name))
{
name = "\u73A9\u5BB6";
}
lastCollapsedPreview = name + ": " + (message.Text ?? string.Empty);
RefreshCollapsedPreview();
}
private void RefreshCollapsedPreview()
{
if (collapsedPreviewText != null)
{
collapsedPreviewText.text = string.IsNullOrEmpty(lastCollapsedPreview)
? (collapsedDefaultText ?? string.Empty)
: lastCollapsedPreview;
}
}
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:
+718
View File
@@ -0,0 +1,718 @@
{
"schemaVersion": 1,
"prefabName": "UI_CommonChat",
"prefabPath": "Assets/Game/Art/UI/Prefab/UI_CommonChat.prefab",
"description": "Normal 层通用聊天界面。默认只显示窗口下方的一行聊天输入入口,点击 Btn_CollapsedInput 触发 Chat.Open 后由运行时控制器隐藏 Panel_Collapsed 并显示 Panel_Chat。展开态包含标题、关闭按钮、消息 ScrollRect、消息模板、底部输入框和发送按钮。JsonUIPrefabBuilder 会为 InputHitArea 生成真实 TMP_InputField,并绑定 Txt_InputValue/Txt_InputPlaceholder;消息克隆、滚动到底和聊天开关由 C# 按 bindings/events 接管。",
"canvas": {
"canvasScaler": {
"referenceResolution": [2048, 1024],
"matchWidthOrHeight": 0.5
}
},
"assets": {
"sprites": {
"ui_panel_main": "Assets/Game/Art/UI/Texture/Common/ui_panel_main.png",
"ui_panel_header": "Assets/Game/Art/UI/Texture/Common/ui_panel_header.png",
"ui_panel_content": "Assets/Game/Art/UI/Texture/Common/ui_panel_content.png",
"ui_panel_footer": "Assets/Game/Art/UI/Texture/Common/ui_panel_footer.png",
"ui_list_item_normal": "Assets/Game/Art/UI/Texture/Common/ui_list_item_normal.png",
"input_bg_normal": "Assets/Game/Art/UI/Texture/Common/input_bg_normal.png",
"input_bg_focus": "Assets/Game/Art/UI/Texture/Common/input_bg_focus.png",
"btn_primary_normal": "Assets/Game/Art/UI/Texture/Common/btn_primary_normal.png",
"btn_primary_hover": "Assets/Game/Art/UI/Texture/Common/btn_primary_hover.png",
"btn_primary_pressed": "Assets/Game/Art/UI/Texture/Common/btn_primary_pressed.png",
"btn_primary_disabled": "Assets/Game/Art/UI/Texture/Common/btn_primary_disabled.png",
"btn_close": "Assets/Game/Art/UI/Texture/Common/btn_close.png",
"ico_chat": "Assets/Game/Art/UI/Texture/Common/ico_chat.png",
"ico_clear": "Assets/Game/Art/UI/Texture/Common/ico_clear.png",
"ico_keyboard": "Assets/Game/Art/UI/Texture/Common/ico_keyboard.png"
}
},
"nodes": [
{
"name": "UI_CommonChat",
"parent": null,
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [0, 0],
"offsetMax": [0, 0]
},
"components": [
{
"type": "Canvas",
"sortingOrder": 12000
},
{
"type": "CanvasScaler",
"referenceResolution": [2048, 1024],
"matchWidthOrHeight": 0.5
},
{
"type": "GraphicRaycaster"
}
]
},
{
"name": "Panel_Collapsed",
"parent": "UI_CommonChat",
"active": true,
"rect": {
"anchorMin": [0.5, 0],
"anchorMax": [0.5, 0],
"pivot": [0.5, 0],
"anchoredPosition": [0, 40],
"sizeDelta": [720, 88]
},
"components": [
{
"type": "CanvasGroup",
"alpha": 0.98,
"interactable": true,
"blocksRaycasts": true
}
]
},
{
"name": "Btn_CollapsedInput",
"parent": "Panel_Collapsed",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [0, 0],
"offsetMax": [0, 0]
},
"components": [
{
"type": "Image",
"sprite": "input_bg_normal",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "input_bg_focus",
"spritePressed": "input_bg_focus",
"spriteDisabled": "input_bg_normal",
"onClick": "Chat.Open"
},
{
"type": "Shadow",
"effectColor": "#06101F55",
"effectDistance": [0, -6],
"useGraphicAlpha": true
}
]
},
{
"name": "Img_CollapsedChatIcon",
"parent": "Btn_CollapsedInput",
"active": true,
"rect": {
"anchorMin": [0, 0.5],
"anchorMax": [0, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [48, 0],
"sizeDelta": [48, 48]
},
"components": [
{
"type": "Image",
"sprite": "ico_chat",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Txt_CollapsedPlaceholder",
"parent": "Btn_CollapsedInput",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [92, 0],
"offsetMax": [-32, 0]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "点击输入消息...",
"fontSize": 30,
"alignment": "Left",
"color": "#6B7E92FF",
"raycastTarget": false
}
]
},
{
"name": "Panel_Chat",
"parent": "UI_CommonChat",
"active": false,
"rect": {
"anchorMin": [0.5, 0],
"anchorMax": [0.5, 0],
"pivot": [0.5, 0],
"anchoredPosition": [0, 40],
"sizeDelta": [960, 720]
},
"components": [
{
"type": "Image",
"sprite": "ui_panel_main",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Shadow",
"effectColor": "#06101F66",
"effectDistance": [0, -10],
"useGraphicAlpha": true
}
]
},
{
"name": "Panel_Header",
"parent": "Panel_Chat",
"active": true,
"rect": {
"anchorMin": [0, 1],
"anchorMax": [1, 1],
"pivot": [0.5, 1],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 104]
},
"components": [
{
"type": "Image",
"sprite": "ui_panel_header",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Img_HeaderChatIcon",
"parent": "Panel_Header",
"active": true,
"rect": {
"anchorMin": [0, 0.5],
"anchorMax": [0, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [58, 0],
"sizeDelta": [52, 52]
},
"components": [
{
"type": "Image",
"sprite": "ico_chat",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Txt_Title",
"parent": "Panel_Header",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [104, 0],
"offsetMax": [-112, 0]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "聊天",
"fontSize": 42,
"fontStyle": "Bold",
"alignment": "Left",
"color": "#102033FF",
"raycastTarget": false
}
]
},
{
"name": "Btn_Close",
"parent": "Panel_Header",
"active": true,
"rect": {
"anchorMin": [1, 0.5],
"anchorMax": [1, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [-56, 0],
"sizeDelta": [72, 72]
},
"components": [
{
"type": "Image",
"sprite": "btn_close",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "ColorTint",
"onClick": "Chat.Close"
}
]
},
{
"name": "Panel_MessageArea",
"parent": "Panel_Chat",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [32, 132],
"offsetMax": [-32, -120]
},
"components": [
{
"type": "Image",
"sprite": "ui_panel_content",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Scroll_Messages",
"parent": "Panel_MessageArea",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [20, 20],
"offsetMax": [-20, -20]
},
"components": [
{
"type": "ScrollRect",
"horizontal": false,
"vertical": true,
"viewport": "Viewport_Messages",
"content": "Content_Messages"
}
]
},
{
"name": "Viewport_Messages",
"parent": "Scroll_Messages",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [0, 0],
"offsetMax": [0, 0]
},
"components": [
{
"type": "Image",
"sprite": "ui_panel_content",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFF00"
},
{
"type": "RectMask2D"
}
]
},
{
"name": "Content_Messages",
"parent": "Viewport_Messages",
"active": true,
"rect": {
"anchorMin": [0, 1],
"anchorMax": [1, 1],
"pivot": [0.5, 1],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 0]
},
"components": [
{
"type": "VerticalLayoutGroup",
"spacing": 14,
"padding": [8, 8, 8, 8],
"childAlignment": "UpperCenter",
"childControlWidth": true,
"childControlHeight": true,
"childForceExpandWidth": false,
"childForceExpandHeight": false
},
{
"type": "ContentSizeFitter",
"verticalFit": "PreferredSize",
"horizontalFit": "Unconstrained"
}
]
},
{
"name": "Item_MessageOther_Template",
"parent": "Content_Messages",
"active": false,
"rect": {
"anchorMin": [0, 1],
"anchorMax": [1, 1],
"pivot": [0.5, 1],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 112]
},
"components": [
{
"type": "Image",
"sprite": "ui_list_item_normal",
"imageType": "Sliced",
"raycastTarget": false,
"color": "#FFFFFFFF"
},
{
"type": "LayoutElement",
"preferredHeight": 112,
"preferredWidth": 840
}
]
},
{
"name": "Txt_OtherName",
"parent": "Item_MessageOther_Template",
"active": true,
"rect": {
"anchorMin": [0, 1],
"anchorMax": [1, 1],
"pivot": [0.5, 1],
"anchoredPosition": [0, -12],
"sizeDelta": [-40, 30]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "玩家",
"fontSize": 22,
"fontStyle": "Bold",
"alignment": "Left",
"color": "#2F6AA8FF",
"raycastTarget": false
}
]
},
{
"name": "Txt_OtherMessage",
"parent": "Item_MessageOther_Template",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [20, 14],
"offsetMax": [-32, -42]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "你好,欢迎来到聊天频道。",
"fontSize": 28,
"alignment": "Left",
"color": "#23384CFF",
"raycastTarget": false
}
]
},
{
"name": "Item_MessageSelf_Template",
"parent": "Content_Messages",
"active": false,
"rect": {
"anchorMin": [0, 1],
"anchorMax": [1, 1],
"pivot": [0.5, 1],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 104]
},
"components": [
{
"type": "Image",
"sprite": "ui_list_item_normal",
"imageType": "Sliced",
"raycastTarget": false,
"color": "#DDF8E6FF"
},
{
"type": "LayoutElement",
"preferredHeight": 104,
"preferredWidth": 840
}
]
},
{
"name": "Txt_SelfMessage",
"parent": "Item_MessageSelf_Template",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [28, 16],
"offsetMax": [-28, -16]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "这是我发送的消息。",
"fontSize": 28,
"alignment": "Right",
"color": "#1E4A2EFF",
"raycastTarget": false
}
]
},
{
"name": "Panel_InputBar",
"parent": "Panel_Chat",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 120]
},
"components": [
{
"type": "Image",
"sprite": "ui_panel_footer",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
}
]
},
{
"name": "InputHitArea",
"parent": "Panel_InputBar",
"active": true,
"rect": {
"anchorMin": [0, 0.5],
"anchorMax": [1, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [-84, 0],
"sizeDelta": [-256, 76]
},
"components": [
{
"type": "Image",
"sprite": "input_bg_normal",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "TMP_InputField",
"textComponent": "Txt_InputValue",
"placeholderComponent": "Txt_InputPlaceholder",
"placeholder": "输入聊天内容...",
"characterLimit": 200,
"lineType": "SingleLine",
"contentType": "Standard"
}
]
},
{
"name": "Img_Keyboard",
"parent": "InputHitArea",
"active": true,
"rect": {
"anchorMin": [0, 0.5],
"anchorMax": [0, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [42, 0],
"sizeDelta": [40, 40]
},
"components": [
{
"type": "Image",
"sprite": "ico_keyboard",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Txt_InputPlaceholder",
"parent": "InputHitArea",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [76, 0],
"offsetMax": [-72, 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": [76, 0],
"offsetMax": [-72, 0]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "",
"fontSize": 28,
"alignment": "Left",
"color": "#23384CFF",
"raycastTarget": false
}
]
},
{
"name": "Btn_ClearInput",
"parent": "InputHitArea",
"active": true,
"rect": {
"anchorMin": [1, 0.5],
"anchorMax": [1, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [-38, 0],
"sizeDelta": [44, 44]
},
"components": [
{
"type": "Image",
"sprite": "ico_clear",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "ColorTint",
"onClick": "Chat.ClearInput"
}
]
},
{
"name": "Btn_Send",
"parent": "Panel_InputBar",
"active": true,
"rect": {
"anchorMin": [1, 0.5],
"anchorMax": [1, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [-92, 0],
"sizeDelta": [160, 76]
},
"components": [
{
"type": "Image",
"sprite": "btn_primary_normal",
"imageType": "Sliced",
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_primary_hover",
"spritePressed": "btn_primary_pressed",
"spriteDisabled": "btn_primary_disabled",
"onClick": "Chat.Send"
}
]
},
{
"name": "Txt_Send",
"parent": "Btn_Send",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"offsetMin": [0, 0],
"offsetMax": [0, 0]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "发送",
"fontSize": 30,
"fontStyle": "Bold",
"alignment": "Center",
"color": "#1F3A0EFF",
"raycastTarget": false
},
{
"type": "Outline",
"effectColor": "#FFFFFFFF",
"effectDistance": [1, -1],
"useGraphicAlpha": true
}
]
}
],
"bindings": [
"Panel_Collapsed",
"Btn_CollapsedInput",
"Panel_Chat",
"Btn_Close",
"Scroll_Messages",
"Viewport_Messages",
"Content_Messages",
"Item_MessageOther_Template",
"Item_MessageSelf_Template",
"Panel_InputBar",
"InputHitArea",
"Txt_InputPlaceholder",
"Txt_InputValue",
"Btn_ClearInput",
"Btn_Send",
"Txt_Title"
],
"events": [
{
"name": "Chat.Open",
"node": "Btn_CollapsedInput"
},
{
"name": "Chat.Close",
"node": "Btn_Close"
},
{
"name": "Chat.ClearInput",
"node": "Btn_ClearInput"
},
{
"name": "Chat.Send",
"node": "Btn_Send"
}
]
}
+1 -1
View File
@@ -25,7 +25,7 @@
"anchorMin": [0, 0], "anchorMin": [0, 0],
"anchorMax": [0, 0], "anchorMax": [0, 0],
"pivot": [0.5, 0.5], "pivot": [0.5, 0.5],
"anchoredPosition": [208, 158], "anchoredPosition": [260, 220],
"sizeDelta": [148, 148] "sizeDelta": [148, 148]
}, },
"components": [ "components": [
@@ -86,6 +86,27 @@ namespace XWorld.Framework.Tests
Assert.Equal("version mismatch", d.Message); Assert.Equal("version mismatch", d.Message);
} }
[Fact]
public void WorldChatMessages_RoundTrip()
{
var send = new WorldChatSendMsg { Text = "hello world" };
WorldChatSendMsg decodedSend = WorldChatSendMsg.Decode(send.Encode());
Assert.Equal("hello world", decodedSend.Text);
var broadcast = new WorldChatMessageMsg
{
PlayerId = 42,
PlayerName = "Alice",
Text = "hello everyone",
ServerTimeMs = 123456789L
};
WorldChatMessageMsg decodedBroadcast = WorldChatMessageMsg.Decode(broadcast.Encode());
Assert.Equal(42, decodedBroadcast.PlayerId);
Assert.Equal("Alice", decodedBroadcast.PlayerName);
Assert.Equal("hello everyone", decodedBroadcast.Text);
Assert.Equal(123456789L, decodedBroadcast.ServerTimeMs);
}
[Fact] [Fact]
public void Opcodes_AreStableValues() public void Opcodes_AreStableValues()
{ {
@@ -97,6 +118,8 @@ namespace XWorld.Framework.Tests
Assert.Equal((ushort)5, (ushort)FrameworkOpcode.Snapshot); Assert.Equal((ushort)5, (ushort)FrameworkOpcode.Snapshot);
Assert.Equal((ushort)6, (ushort)FrameworkOpcode.RoomEnd); Assert.Equal((ushort)6, (ushort)FrameworkOpcode.RoomEnd);
Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error); Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error);
Assert.Equal((ushort)16, (ushort)FrameworkOpcode.WorldChatSend);
Assert.Equal((ushort)17, (ushort)FrameworkOpcode.WorldChatMessage);
} }
[Fact] [Fact]
+33
View File
@@ -32,6 +32,10 @@ namespace XWorld.Server.Gateway.Tests
Moving = moving Moving = moving
}.Encode()); }.Encode());
private static Frame WorldChatFrame(string text)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.WorldChatSend,
new WorldChatSendMsg { Text = text }.Encode());
private static List<Frame> Frames(FakeConnection c) private static List<Frame> Frames(FakeConnection c)
{ {
var list = new List<Frame>(); var list = new List<Frame>();
@@ -85,6 +89,35 @@ namespace XWorld.Server.Gateway.Tests
Assert.True(decoded.Moving); Assert.True(decoded.Moving);
} }
[Fact]
public void WorldChat_IsBroadcastToAllConnectedPlayers()
{
var loop = NewLoop();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyJoinFrame("Alice") });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = LobbyJoinFrame("Bob") });
loop.DrainAndTick(0f);
c1.Sent.Clear();
c2.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = WorldChatFrame("hello world") });
loop.DrainAndTick(0f);
Frame selfFrame = Frames(c1).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.WorldChatMessage);
Frame otherFrame = Frames(c2).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.WorldChatMessage);
Assert.NotEqual(default, selfFrame);
Assert.NotEqual(default, otherFrame);
WorldChatMessageMsg message = WorldChatMessageMsg.Decode(otherFrame.Payload);
Assert.Equal(1, message.PlayerId);
Assert.Equal("Alice", message.PlayerName);
Assert.Equal("hello world", message.Text);
Assert.True(message.ServerTimeMs > 0);
}
[Fact] [Fact]
public void Reconnect_ReroutesRoomOutputToNewConnection() public void Reconnect_ReroutesRoomOutputToNewConnection()
{ {
+43
View File
@@ -21,6 +21,7 @@ namespace XWorld.Server.Gateway
private readonly Matchmaker _matchmaker; private readonly Matchmaker _matchmaker;
private readonly ChannelsNS.Channel<ServerCommand> _cmds = private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>(); ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
private const int MaxWorldChatTextLength = 200;
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>(); private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>(); private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
@@ -156,6 +157,9 @@ namespace XWorld.Server.Gateway
case FrameworkOpcode.LobbyLeave: case FrameworkOpcode.LobbyLeave:
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState(); if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
break; break;
case FrameworkOpcode.WorldChatSend:
HandleWorldChatSend(pid, frame.Payload);
break;
} }
} }
@@ -213,6 +217,45 @@ namespace XWorld.Server.Gateway
} }
} }
private void HandleWorldChatSend(int pid, byte[] payload)
{
if (!_sessions.IsConnected(pid))
{
_logger.Warn($"world chat ignored pid={pid} reason=not_connected");
return;
}
var req = WorldChatSendMsg.Decode(payload);
string text = (req.Text ?? string.Empty).Trim();
if (text.Length == 0)
{
_logger.Warn($"world chat ignored pid={pid} reason=empty");
return;
}
if (text.Length > MaxWorldChatTextLength)
{
text = text.Substring(0, MaxWorldChatTextLength);
}
string name = _lobbyPlayers.TryGetValue(pid, out var player) && !string.IsNullOrWhiteSpace(player.Name)
? player.Name
: $"P{pid}";
var msg = new WorldChatMessageMsg
{
PlayerId = pid,
PlayerName = name,
Text = text,
ServerTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
byte[] encoded = msg.Encode();
IReadOnlyList<int> targets = _sessions.ConnectedPlayerIds();
_logger.Info($"world chat pid={pid} name={name} length={text.Length} targets={targets.Count}");
foreach (int targetPid in targets)
{
SendFramework(targetPid, FrameworkOpcode.WorldChatMessage, encoded);
}
}
private void BroadcastLobbyState() private void BroadcastLobbyState()
{ {
if (_lobbyPlayers.Count == 0) return; if (_lobbyPlayers.Count == 0) return;
+13
View File
@@ -38,6 +38,19 @@ namespace XWorld.Server.Gateway
public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null; public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;
public IReadOnlyList<int> ConnectedPlayerIds()
{
List<int> players = null;
foreach (var kv in _byPid)
{
if (kv.Value.Connected)
{
(players ??= new List<int>()).Add(kv.Key);
}
}
return players ?? (IReadOnlyList<int>)Array.Empty<int>();
}
/// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick &gt; windowTicks)。返回被移除的会话;无则空数组。</summary> /// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick &gt; windowTicks)。返回被移除的会话;无则空数组。</summary>
public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks) public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
{ {
@@ -0,0 +1,131 @@
# Unity Humanoid Animation Import Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Unity editor import hook that applies the shared Cityboy humanoid avatar and root transform clip settings to Actor animation FBX imports.
**Architecture:** Create a small pure-policy helper plus an `AssetPostprocessor` wrapper in the existing editor assembly. EditMode tests exercise the helper so behavior can be verified without importing binary FBX assets.
**Tech Stack:** Unity Editor C#, `UnityEditor.ModelImporter`, `UnityEditor.ModelImporterClipAnimation`, NUnit EditMode tests.
## Global Constraints
- Scope is `Assets/Game/Art/Actor/`.
- Exclude `Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx`.
- Only mutate imports that have one or more animation clips.
- Use `Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx` as the source avatar asset path.
- Do not touch existing FBX, prefab, or controller asset files.
---
### Task 1: Import Policy Tests And Helper
**Files:**
- Create: `Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs`
- Create: `Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs`
- Modify: `Client/Assets/Script/Tests/EditMode/XWorld.Link.EditModeTests.asmdef`
**Interfaces:**
- Produces: `HumanoidAnimationFbxImportSettings.ShouldProcessAsset(string assetPath, bool hasAnimationClips) : bool`
- Produces: `HumanoidAnimationFbxImportSettings.ApplyClipSettings(ModelImporterClipAnimation clip) : ModelImporterClipAnimation`
- Produces: `HumanoidAnimationFbxImportSettings.CityboyAvatarPath : string`
- [ ] **Step 1: Write failing EditMode tests**
```csharp
using NUnit.Framework;
using UnityEditor;
public sealed class HumanoidAnimationFbxImportSettingsTests
{
[Test]
public void ShouldProcessActorAnimationFbx()
{
Assert.That(
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
"Assets/Game/Art/Actor/Controller/Class/Caster/Standing Walk Forward.fbx",
true),
Is.True);
}
[Test]
public void ShouldNotProcessCityboySourceAvatar()
{
Assert.That(
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
HumanoidAnimationFbxImportSettings.CityboyAvatarPath,
true),
Is.False);
}
[Test]
public void ShouldNotProcessActorFbxWithoutAnimationClips()
{
Assert.That(
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
"Assets/Game/Art/Actor/captain/captain.fbx",
false),
Is.False);
}
[Test]
public void ApplyClipSettingsUsesOriginalRotationAndBakesRotationAndY()
{
var clip = new ModelImporterClipAnimation
{
lockRootRotation = false,
keepOriginalOrientation = false,
lockRootHeightY = false
};
ModelImporterClipAnimation updated = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clip);
Assert.That(updated.lockRootRotation, Is.True);
Assert.That(updated.keepOriginalOrientation, Is.True);
Assert.That(updated.lockRootHeightY, Is.True);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `HumanoidAnimationFbxImportSettingsTests`.
Expected: compile/test failure because `HumanoidAnimationFbxImportSettings` does not exist.
- [ ] **Step 3: Implement minimal helper**
Create `HumanoidAnimationFbxImportSettings` with the constants and pure helper methods used by the tests.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `HumanoidAnimationFbxImportSettingsTests`.
Expected: all tests in the fixture pass.
### Task 2: AssetPostprocessor Integration
**Files:**
- Modify: `Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs`
- Test: `Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs`
**Interfaces:**
- Consumes: `HumanoidAnimationFbxImportSettings.ShouldProcessAsset(string assetPath, bool hasAnimationClips) : bool`
- Consumes: `HumanoidAnimationFbxImportSettings.ApplyClipSettings(ModelImporterClipAnimation clip) : ModelImporterClipAnimation`
- Produces: `HumanoidAnimationFbxPostprocessor : AssetPostprocessor`
- [ ] **Step 1: Add AssetPostprocessor implementation**
Use `OnPreprocessModel` to get the `ModelImporter`, check scope and clip count, load the Cityboy avatar, set humanoid/copy-from-other settings, and update `clipAnimations`.
- [ ] **Step 2: Run editor compilation/tests**
Run Unity EditMode tests.
Expected: editor code compiles and the helper tests still pass.
- [ ] **Step 3: Inspect git diff**
Run: `git diff -- Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs Client/Assets/Script/Tests/EditMode/XWorld.Link.EditModeTests.asmdef`
Expected: only the new importer helper/postprocessor and test assembly reference changes are present.
@@ -0,0 +1,32 @@
# Unity Humanoid Animation Import Design
## Goal
Automatically apply the project's shared humanoid animation import settings when FBX animation assets are imported under `Assets/Game/Art/Actor/`.
## Scope
- Applies to `.fbx` assets inside `Assets/Game/Art/Actor/`.
- Excludes `Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx`, because it provides the source avatar.
- Applies only when the FBX import has animation clips.
- Does not modify existing FBX, prefab, or controller assets directly.
## Import Behavior
When an in-scope FBX with animation clips is imported:
- Set Rig `Animation Type` to `Humanoid`.
- Set Avatar to `Copy From Other`.
- Use `Cityboy_sk Avatar` from `Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx`.
- For every imported animation clip:
- Enable Root Transform Rotation `Bake Into Pose`.
- Set Root Transform Rotation `Based Upon` to `Original`.
- Enable Root Transform Position (Y) `Bake Into Pose`.
## Error Handling
If the Cityboy avatar cannot be loaded, the importer logs a warning and leaves the asset unchanged. This avoids blocking Unity's asset import pipeline.
## Testing
Editor tests cover the path filter, avatar exclusion, and clip-setting mutation logic without importing real FBX files.