Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
053a95b4fb | ||
|
|
48aed26725 | ||
|
|
09c89aadf0 |
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 > windowTicks)。返回被移除的会话;无则空数组。</summary>
|
/// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick > 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.
|
||||||
Reference in New Issue
Block a user