关于 editable-ui
这项技能指导生成或改造 Creator Hub UI 代码,使可视化编辑器能够解析和修改界面。重点说明保持场景 UI 可编辑的 React-ECS 代码约定。
主要功能
- 01编辑器可读的 JSX 规则
- 02组件与文件布局指南
- 03改造现有 UI 的模式
- 04布局与安全区行为
- 05代码自检指南
适用场景
- 01为 Creator Hub 可视化编辑器创建 UI
- 02改造现有 React-ECS 界面
- 03排查界面只读或显示为不透明块
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill editable-ui安装后在 Claude Code 中输入/editable-ui确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/adapting-coded-ui.mdcomponent-template.mddrag-slider.mddriver-pattern.mdinteraction-helper.md详细介绍
可编辑 UI(Creator Hub UI 设计器)
Creator Hub UI 编辑器没有自己的保存格式:场景中 src/ui/ 下真正的 @dcl/react-ecs .tsx 文件就是文档。编辑器会将它们解析为节点树,在画布上渲染,并通过最小文本片段修改将可视化编辑写回文件。每隔 1s 执行一次的磁盘监视器会将外部编辑反映到画布上。
因此,“可在编辑器中编辑”是代码的一项可检查属性,而不是一种风格偏好。解析器无法静态理解的代码会以以下两种方式之一退化:
| 退化情况 | 触发条件 | 效果 |
|---|---|---|
冻结节点(dynamicProps) | 任何 uiTransform / uiBackground 值,或任何 Label/Input/Dropdown 属性既不是字面值,也不是不带修饰的引用 | 节点仍会在画布上渲染,但面板会拒绝对此节点进行任何编辑 |
| 不透明节点 | 未知元素名称、展开属性、条件/逻辑/.map() 子元素、JSX 注释 | 灰色只读块;不会遍历其子元素,因此整个子树都会从画布上消失 |
这两种情况都不会发出提示。遵循以下约定编写代码,就不会出现这些情况。
前置知识:build-ui 技能(React-ECS 元素、uiTransform/uiBackground、弹性布局)。本技能仅介绍如何编写可由代码编辑器编辑的代码。
Creator Hub 中的 UI 编辑器稳定且始终启用(0.50.0+,creator-hub 66758e1c)。不再有 Settings > Experimental 开关,也没有 settings.guiEditor 键——不要告知用户需要启用它。唯一剩下的门槛是 SDK 版本:场景必须使用 @dcl/sdk 7.26.0+(该版本引入了 ScreenInsetArea / InteractableArea 和按设备提供的默认虚拟屏幕)。低于此版本时,进入 2D 模式会显示带有 Update SDK 按钮的 SDK 升级提示,而不是画布。遵循此约定编写的代码是普通 React-ECS 代码,无论如何都能运行。
编辑器的行为方式(确保代码与其写入的内容一致)
已根据 creator-hub docs/UIDesigner.md 和实现提交(35cd6fa7、33134497、30207181、abb88456、6753eda9)核实。
保存会立即完成。 每次可视化编辑都会当场写入 .tsx;状态徽章显示 “All changes saved”,无需手动保存。没有需要保持同步的独立文档——编辑器打开时,应将文件视为实时内容。
布局模型有两个相互独立的轴。 不要混为一谈:
| 轴 | 面板控件 | 写入的属性 |
|---|---|---|
| 流式布局 | Flow(row / column / Free) | 仅 flexDirection。Free 表示完全没有 flexDirection——该键不存在,而不是设为某个“free”值。 |
| 脱离流式布局 | Ignore Layout Flow | positionType('absolute') |
编写代码时需要考虑的影响:
- 新放置的子项仅当其父项为 Free 时才会以
positionType: 'absolute'初始化,并落在放置点(根据四舍五入后的坐标设置position: { top, left })。父项为 row/column 时,子项会加入流式布局。 - 根节点始终为 absolute。
- 将父项切换为 Free 时,会通过一次批量编辑,将每个现有子项固定在其测量位置。 预期差异中会为你未触碰的子项新增
positionType/position。
控件预设。 Full Screen 控件会插入 uiTransform={{ flexGrow: 1, alignSelf: 'stretch' }},不设置 width/height——这是有意为之,并非 100% x 100%,因为 100% 读回时会变成 Percent 单位而非 Fill,并且两个 100% 同级元素会溢出 Yoga 的可用空间。在 Free 父项下,它会改用 absolute 变体(positionType: 'absolute',并将 top/right/bottom/left 设为 0)。如果你手动编写全屏包装器,请使用同一组合。(还要记住指针规则:不要在全屏包装器上添加交互展开属性——参见 交互层。)
Scene Inset。 下拉菜单提供 device / interactable / none 选项,可将顶层根节点包装在 <ScreenInsetArea>、<InteractableArea> 中,或不进行包装。默认值为 device。这与渲染器的 screenInset 选项相同(参见 build-ui),只是以结构化方式应用在源文件中。
Opacity,而非 Transparency。 样式字段已更名为 Opacity,以百分比显示,默认值为 100%(100% 不透明,0% 透明)。旧笔记中称其为“Transparency”的说法已过时——“Transparency”现在仅存在于无关的材质检查器中。
画布功能: 根节点的宽度和高度均为固定 px(单位为点)时,会启用画板取景;吸附网格为 10px,按住 Shift 可覆盖吸附;工具模式为 Free / Move / Resize(Free 将拖动移动与缩放手柄合并;旋转按钮存在但已禁用)。
模式持久化。 2D/3D 模式按场景存储在 .editor/project.json 中,字段为 uiDesignerOpen,而非存储在复合场景中。不要在 inspector::UIState 中查找该字段——它已被移除。手动编辑项目文件时请注意:.editor/project.json 属于编辑器状态,而非场景内容。
在 Bevy 渲染器下,打开 2D 模式会冻结场景(如果场景此前正在运行,返回 3D 模式时会恢复)。用户在 Bevy 中布局 UI 时不会产生 tick——驱动器的时钟不会前进。
两种工作流
A. 生成新的可编辑 UI
- 每个组件单独放在一个文件中:
src/ui/<ComponentName>.tsx。文件名必须是有效的 PascalCase 标识符,并且与导出的函数名一致——文件名不符合此格式时,编辑器会忽略该文件。 - 根据
{baseDir}/references/interaction-helper.md原样创建src/ui/interaction.tsx,如果使用平台变体,还要创建src/ui/platform.tsx。这两个文件名是保留的辅助文件名;编辑器会自动生成它们,并且不会将它们列为 UI。 - 按照下文的组件契约编写每个组件。从
{baseDir}/references/component-template.md开始。 - 按照生成的确切结构编写
src/ui/index.tsx(参见聚合文件),并在src/index.ts的main()中调用setupUi()。 - 所有时钟、缓动、计时器、格式化器和状态机都放在
src/ui/外的普通.ts文件中——参见驱动程序模式。 - 对你创建的每个文件运行本文末尾的自检清单。
B. 适配现有的编码 UI
- 将 UI 拆分为
src/ui/下的多个文件,每个组件一个文件;将所有非 JSX 逻辑(辅助函数、时钟、数学运算、数据)移至src/ui/外的.ts文件中。 - 按以下顺序机械地消除五个阻碍——每项的前后代码示例请参见
{baseDir}/references/adapting-coded-ui.md:.map()/ 循环 → 将每一项展开为同级元素。{cond && <X/>}和cond ? <A/> : <B/>→ 使用useInteraction的active层,并设置display: 'none'(唯一例外是桌面端 / 移动端平台变体,它是受支持的结构)。将每个条件门放在要隐藏的元素上,而不要放在包裹全屏内容的容器上——展开内容会携带指针处理器(参见交互层)。- 计算得到的样式值(算术运算、
Math.*、函数调用、字符串拼接、三元表达式、共享主题标识符)→ 为每个派生值使用一个由驱动程序维护的绑定状态变量,或使用内联字面值。 - 内联插值文本 → 使用混合文本片段绑定,并将格式化逻辑移至驱动程序中。
- 手动跟踪的悬停 / 按下布尔值 → 使用
hover/press层。
- 将在渲染时计算样式值的补间 / 动画代码替换为驱动程序,由它将最终值缓动至绑定状态变量。
- 所有尺寸和位置值都必须是纯 px 数字,并且每个组件根节点和组件引用包装器都必须显式设置
width/height——编码 UI 通常会根据子元素自动调整容器大小,这种行为在移植后仍会保留,导致容器在画布上的高度变为 0(参见尺寸和移动端)。 - 重新运行自检清单。
适配后的文件预计会比原文件更大:展开循环和变体是实现可编辑性的代价。请向用户说明这一取舍,而不是悄悄地只完成部分移植。
文件布局
src/ui/
index.tsx <- GENERATED aggregator. Never hand-edit.
interaction.tsx <- reserved helper (useInteraction). Verbatim.
platform.tsx <- reserved helper (usePlatform). Verbatim.
MyScreen.tsx <- a top-level UI (rendered by the aggregator)
MyWidget.tsx <- a reusable component (marked /** @ui-component */)
src/ui-behaviors.ts <- driver: outside src/ui/, never parsed by the editor
src/mobile-hud.ts <- GENERATED by the MobileHUD panel. Editor-owned. Never hand-edit.
- 编辑器首次打开场景时,会将旧版单文件
src/ui.tsx备份为src/ui.tsx.bak,然后删除原文件。始终在src/ui/下编写文件。 - 每个文件都以
/** @jsx ReactEcs.createElement */开头,并从@dcl/sdk/react-ecs导入ReactEcs。 - 每个文件恰好导出一个组件(编辑器会读取第一个返回 JSX 的导出函数)。
- 仅使用
//注释。{/* JSX comments */}会被解析为不透明的表达式子节点。
聚合器(src/ui/index.tsx)
根据顶层根文件列表生成。每当编辑器打开场景,或添加、重命名、移除根文件或为根文件重新设置内嵌方式时,都会重写此文件,因此 手动修改都会丢失。必须严格按以下格式生成:
/** @jsx ReactEcs.createElement */
import ReactEcs, { UiEntity, ReactEcsRenderer, ScreenInsetArea } from '@dcl/sdk/react-ecs'
import { MyScreen } from './MyScreen'
export function setupUi() {
ReactEcsRenderer.setUiRenderer(() => (
<UiEntity uiTransform={{ width: '100%', height: '100%' }}>
<ScreenInsetArea>
<MyScreen />
</ScreenInsetArea>
</UiEntity>
))
}
屏幕内嵌方式按根文件单独选择,默认值为 device。 已根据 @dcl/react-ecs 7.27.0(components/ScreenInsetArea、components/InteractableArea)验证:
| 内嵌方式 | 包装元素 | 适用场景 |
|---|---|---|
device(默认) | <ScreenInsetArea> | 所有常规场景 UI——将子元素限制在设备安全区域内(刘海、状态栏、Home 指示条、圆角);桌面端的内嵌区域通常为零 |
interactable | <InteractableArea> | 还必须避开资源管理器自身屏幕控件的 UI |
none | 无——裸 <Component /> | 仅在确实需要控制整个画布时使用:黑边、全屏背景,或刻意绘制到平台 UI 所在区域 |
ScreenInsetArea 自带 positionType/position;尺寸设为 100%×100% 的子元素会恰好填满安全区域。两个包装元素都只能放在 index.tsx 中——若放在组件文件内,解析器将无法识别其元素名称,并会导致子树变得不透明。
生成的 setUiRenderer 不带选项对象,因此场景会使用 SDK 平台默认的虚拟画布(桌面端 1920x1080,移动端 1600x720)。不要在此处手动添加 virtualWidth/virtualHeight——下次重新生成时会移除它们。这是唯一无法遵循 build-ui“始终显式传入虚拟尺寸”规则的地方;请根据这两种画布设计 px 值。
编辑器还会确保 main() 调用 setupUi()(取消模板中 //setupUi() 行的注释并添加导入)。手动编写时,请自行完成接线。
MobileHUD(src/mobile-hud.ts)——由编辑器管理,禁止手写
Creator Hub 0.50.0+(creator-hub 75ad6f5e)。MobileHUD 是 UI Designer 的 GUI 栏中的固定首项(场景中至少有一个 GUI 时才会显示)。它用于编辑场景的 TouchScreenControls——原生移动端摇杆、准星和游戏手柄按钮——并提供触控 HUD 的只读画布预览。
它不是 react-ecs UI 根节点。 它是写入 src/mobile-hud.ts 的一条 TouchScreenControls.createOrReplace(engine.RootEntity, { … }) 调用——刻意放在 src/ui/ 之外,这样根节点扫描器就不会将其视为 GUI。它以 setupMobileHud 为名导出,并在 src/index.ts 中与 setupUi() 相邻的位置从 './mobile-hud' 导入。
在使用 UI Designer 的场景中工作的 Agent 必须遵守以下规则:
- 绝不要手写或编辑
src/mobile-hud.ts。 面板会根据自己对该精确格式的解析重新生成此文件,无法解析的代码会被静默还原为默认值。 - 绝不要在此类场景的其他位置再写一条
TouchScreenControls.createOrReplace。engine.RootEntity上只有一个该组件,并且后写入的值会生效,因此其他位置手写的调用要么会被面板的调用覆盖,要么会静默覆盖面板的调用。 - 该文件会延迟写入,并自动删除。 只有配置偏离 SDK 默认值时它才会存在;重置所有字段或删除最后一个 GUI 时,该模块会被删除,
src/index.ts中的关联代码也会一并移除。场景中没有此文件并不表示 HUD“缺失”——这表示它使用的是默认行为。 - 要在使用 UI Designer 的场景中通过代码更改移动 HUD,请在面板中修改。如果场景确实需要运行时控制(例如过场期间隐藏按钮,或根据游戏状态切换图标),请将其放在
src/ui/之外的驱动程序中,并接受它会与面板的静态调用冲突——组件 API 及其扩展辅助工具请参阅 advanced-input。
面板提供的选项包括:hideJoystick、hideCrosshair、默认值为 IA_JUMP 的 mainAction,以及 IA_JUMP、IA_POINTER、IA_PRIMARY(E)、IA_SECONDARY(F)、IA_ACTION_3–IA_ACTION_6(1–4)各按钮对应的 { hide, icon }。“隐藏输入操作”是派生值(所有操作均已隐藏),并非存储字段。图标仅限场景图片——不支持外部 URL、头像或视频纹理。不能拖动重新排序:资源管理器会按固定优先级顺序渲染按钮,只有 mainAction 可以将某个按钮提升至中央位置。
生成的文件会输出 PBTouchScreenControls 的所有必需字段,包括零值,因为 createOrReplace 接收的是完整 protobuf 消息,而不是 Partial。在这里省略默认值会导致 TS2741 编译错误;这与 react-ecs 编写属性时恰好相反,因为在后者中省略默认值才是正确做法。
约定
元素
目前仅建模以下五种元素名称:UiEntity、Label、Input、Dropdown、Button。
唯一额外的一等元素是对 src/ui/ 中另一个根文件的引用——<MyWidget />。这是获准的复用单元:可选择、可移动,并可编辑每个实例的 props。其他任何元素名称(本地辅助组件、库组件、ScreenInsetArea)都视为不透明内容。
- 文本仅存在于
Label/Button上:value、fontSize、textAlign、color、font、textWrap。UiEntity上的uiText={{...}}属性包不会建模——请将其重构为Label子元素。任何文本值中都不能包含 emoji(参见尺寸与移动端)。 Inputprops:placeholder、value、color、placeholderColor、disabled、textAlign、font、fontSize。Dropdownprops:acceptEmpty、emptyLabel、options、selectedIndex、disabled、color、textAlign、font、fontSize。<MyWidget />实例无法从外部移动或调整大小——需要边距或定位时,请为每个实例提供一个包装UiEntity。该包装还必须显式设置width/height,不能只设置边距(参见尺寸与移动端)。- 组件引用不接受嵌套 JSX 子元素——没有插槽/
children机制,因此无法表达通用的<Card>包装器。
状态:绑定接口
export interface State {
score: number
label: string
visible: boolean
panelWidth: number
labelColor: { r: number; g: number; b: number; a: number }
options: string[]
}
export const state: State = {
score: 0,
label: '00.00',
visible: false,
panelWidth: 320,
labelColor: { r: 1, g: 1, b: 1, a: 1 },
options: ['A', 'B'],
}
模块级的 export interface State + export const state: State 是可识别的签名;每个属性都是可编辑变量。支持的类型:number、string、boolean、Color4(以 { r: number; g: number; b: number; a: number } 的形式进行结构化注解——匹配依据是具有 r/g/b 成员,而不是名称 Color4),以及 string[]。
state是一个普通的导出模块对象——正因如此,驱动程序才能修改它(见下文)。- 模块状态由文件的所有实例共享。每个实例的值必须存放在父级中,并作为 props 传入(React 的受控组件模式)。
number[]变量(例如uvs四边形)可以绑定且能正常工作,但面板会根据初始化值推断为string[],并错误地将其标记为选项列表。可以使用,只是标签错误。
样式绑定
任何 uiTransform / uiBackground 键,只要其值是裸引用——state.x 或 props.x,且不含运算符、调用或拼接——就是一等可编辑绑定,而不是冻结值:
uiTransform={{ width: state.panelWidth, position: { top: state.panelTop }, borderColor: state.frameColor }}
uiBackground={{ color: state.panelColor, texture: { src: state.iconSrc } }}
可识别的绑定位置:顶层键(width、height、zIndex、color、uvs 等)、嵌套边缘组的成员(position/margin/padding → { left: state.x })、一次性写入的整个组(borderColor: state.c),以及点分路径 texture.src / avatarTexture.userId。同一对象中的字面值和绑定值可以自由混用。嵌套深度为一层——与 react-ecs 自身的结构完全一致。
文本和元素 props 的绑定方式相同:value={state.label}、color={state.labelColor}、fontSize={state.size}、selectedIndex={state.i}、options={state.options}。
混合文本(计数器/计时器/读数的常用写法)
插值全部为裸引用的模板字面量会往返转换为有序的字面值/绑定片段,并保持完全可编辑:
<Label value={`Score: <b>${state.score}</b>`} />
<Label value={`${state.mins}:${state.secs}`} />
<Label value={`env: ${state.realm}\nplayers: ${state.count}`} />
多行 \n 字面值没有问题。只要有一个计算插值(${state.a + 1}、${fmt(state.t)}),整个属性就会失效并冻结节点——请在驱动程序中完成格式化,再插入最终值。
(以上三行仅展示了 value 属性。每个实际的 Label 还需要显式设置 uiTransform 的 width/height——参见尺寸与移动端。)
动作(事件处理程序)
type UiAction = { state: State; props: Parameters<typeof MyScreen>[0]; value?: unknown }
/** @ui-action */
function openPanel({ state }: UiAction) {
state.visible = true
}
使用标准 thunk 进行绑定:onMouseDown={() => openPanel({ state, props })};对于携带值的事件,则使用 onChange={(value) => setName({ state, props, value })}。可识别的事件:onMouseDown、onMouseUp、onMouseEnter、onMouseLeave,以及 Input/Dropdown 上的 onChange/onSubmit。
- 处理程序主体是自由形式的代码——允许使用任何逻辑。它们绝不会被解析为样式。
- 无法识别的处理程序表达式(带块主体的内联箭头函数)只会被视为未绑定;它不会冻结节点。
- 动作会同步修改状态。任何基于时间的逻辑都应放在驱动程序中——动作设置标志,驱动程序负责动画。
交互层:悬停、按下和可见性
useInteraction 是可识别的、用于逐状态设置样式的构造。各层按 base → active → hover → press 的优先顺序进行深度合并;第二个参数用于驱动 active 层,且可以是任意表达式(原样存储)。
const panel = useInteraction(
{
base: { uiTransform: { display: 'flex', width: 320, height: 200 } },
active: { uiTransform: { display: 'none' } },
},
state.visible !== true,
)
return <UiEntity {...panel}>…</UiEntity>
规则:
- **可见性始终是
active显示门控。**切勿使用{state.visible && <X/>}(不透明),也不要使用display: state.visible ? 'flex' : 'none'(冻结)。 - **悬停/按下反馈始终应放在
hover/press层中。**切勿使用onMouseEnter/onMouseLeave手动跟踪布尔值。 {...someInteractionConst}是解析器唯一接受的展开语法。任何其他展开都会使节点变得不透明。展开旁边可以有额外属性(<UiEntity {...panel} onMouseDown={…}>)。- 展开会携带指针事件处理程序,因此绝不能放在全屏包装元素上。
useInteraction无条件返回全部四个监听器(onMouseDown/Up/Enter/Leave)——它需要这些监听器来跟踪悬停和按下状态,即使你只将其用作可见性门控,且只设置了base+active层也是如此。带有任何监听器的 UI 元素都会捕获整个矩形范围内的指针输入,因此,在100%×100%的布局包装元素上使用{...gate}会导致整个屏幕吞掉点击:其他 UI 元素和 3D 世界中的任何对象都无法点击,但 UI 看起来仍然正常,因为可见面板很小。将门控放在面板上——即应用可见性判定的最小元素上;让全屏包装元素保持为只用于定位、采用字面样式的普通UiEntity。请参阅{baseDir}/references/component-template.md§4 中对话框的前后示例,以及{baseDir}/references/adapting-coded-ui.md§4 中的配方。只有两种刻意阻挡点击的全屏覆盖层是允许的:用于吞掉点击的模态背景,以及在没有拖动时禁用的拖动释放捕获层({baseDir}/references/drag-slider.md)——规则是,阻挡行为必须始终是显式且受门控的决策,绝不能成为展开位置带来的副作用。 - 元素会被隐藏,而不是卸载——所有内容始终都在树中,画布会渲染
base层,因此编辑时所有状态都会堆叠显示。这是预期行为。 - 对于需要在消失前播放退出动画的 UI,请使用双变量模式:
visible(意图,由动作切换)加上hidden(渲染门控,仅在退出动画结束后由驱动程序清除)。使用state.hidden === true进行门控。
组件属性
/** @ui-component */
export function MyWidget(props: { label?: string; fillPx?: number; on?: boolean; onPress?: (value?: unknown) => void }) {
- 导出函数前的
/** @ui-component */会将文件标记为可复用组件(仅在其他根节点嵌套该组件的位置渲染)。没有此标记时,文件就是顶层根节点,由聚合器渲染。 - 声明的属性必须是单个
props参数上的内联对象类型。支持的类型:number、string、boolean和回调((value?: unknown) => void)。其他类型会显示为只读。属性始终应声明为可选。 - 在组件内部,
props.x会加入绑定范围:样式键、文本值以及useInteraction的激活表达式(props.active === true)都可以引用它。 - 不支持颜色、纹理数组或 children 属性。无法通过
variant属性选择颜色——应在组件内部将每种视觉变体展开为同级元素,并通过active显示层控制它们。 value={props.label}会导致严格 TS 检查失败(string | undefined)。应使用value={`${props.label}`}——这样可以通过类型检查,也仍会被识别为绑定(如果父组件未提供该属性,则会渲染文本undefined)。- 使用单行操作将子组件的回调向上转发:
/** @ui-action */ function forwardClose({ props }: UiAction) { props.onClose?.() }。
平台变体——唯一的结构性条件表达式
const platform = usePlatform()
return platform === 'mobile' ? <PhoneMenu /> : <DesktopBar />
可在组件的 return 处以及 JSX 子元素中识别。!==、反转操作数顺序,以及内联的 usePlatform() === 'mobile' 都可以解析。两个分支都必须是单个 JSX 元素或字面量 null,且至少一个分支必须是元素。其底层由保留的 src/ui/platform.tsx 辅助文件提供支持。其他条件表达式均无法识别。
当不同设备确实需要不同结构时,使用此功能。不支持按属性覆盖——虚拟画布已处理比例缩放。
无法表达的内容
不要尝试以下做法;请改用列出的替代方案。
| 无法表达的内容 | 替代方案 |
|---|---|
循环/对数据使用 .map() | 手动展开每个元素 |
共享主题常量(color: THEME.primary) | 在每个位置内联 { r, g, b, a } 字面量(样式对象中的标识符会冻结节点) |
计算样式值(算术运算、Math.*、函数调用、拼接、三元表达式) | 每个派生值使用一个由驱动程序维护的状态变量 |
百分比字符串绑定(width: state.pct + '%') | 绑定 px 数值;静态百分比字面量(width: '90%')可以使用 |
条件元素属性(disabled={state.i === -1}) | 预先计算的布尔状态变量 |
| 同一文件中的本地辅助组件 | 单独的 src/ui/ 文件,并标记为 /** @ui-component */ |
| 组件上的 children/插槽 | 将布局内联保留在屏幕文件中;仅将叶子小组件提取出来 |
| 将颜色或纹理集作为属性 | 在组件内部展开各种变体(texture.src 可以绑定到字符串属性;uvs 不可以) |
| 带有逐行草稿和闭包的数据驱动行 | 不具备可移植性——告知用户,这部分必须保留为代码,并放在独立的非 src/ui/ 模块中 |
九宫格背景(textureMode: 'nine-slices' + textureSlices)和字面量 8 浮点数 uvs 图集裁剪可以往返转换。
驱动器模式
编辑器不会解析 src/ui/ 以外的文件,并且 state 是一个普通的导出对象。因此:编辑器负责结构、样式和静止值;驱动器负责时钟和数学运算。
// src/ui-behaviors.ts — outside src/ui/, invisible to the editor
import { engine } from '@dcl/sdk/ecs'
import { state as panel } from './ui/MyPanel'
const OPEN_WIDTH = 480
let anim = 0
export function registerUiBehaviors() {
engine.addSystem((dt: number) => {
const target = panel.visible ? 1 : 0
anim = Math.max(0, Math.min(1, anim + (target > anim ? dt : -dt) / 0.25))
const t = 1 - Math.pow(1 - anim, 3) // easeOutCubic
panel.panelWidth = Math.max(1, Math.round(OPEN_WIDTH * t))
panel.textColor.a = t
if (target === 0 && anim <= 0) panel.hidden = true // release the display gate
})
}
在 main() 中注册。state 中每个绑定变量的初始值都是其可设计的静止状态——在注册时捕获该值,并以此为基准进行动画处理,这样设计师就可以在面板中调整样式,而无需改动驱动器。
驱动器负责:时钟、补间、缓动、计时器和自动隐藏截止时间、padStart/舍入/文本格式化、状态机、派生值(根据百分比计算 px 宽度),以及任何读取 ECS 的内容(玩家位置等)。动画本身没有对应的编辑器表示——编辑器只会看到绑定的键及其静止值。
完整示例(缓动打开/关闭、格式化的计时器标签、双变量退出门、点击触发的一次性动画、命名约定驱动器):{baseDir}/references/driver-pattern.md。
尺寸与移动端
- 每个
/** @ui-component */文件的根元素都必须显式声明width和height——值可以是 px 数字或百分比字面量。绝不要依赖子元素的自动/适合内容尺寸。画布会根据组件实例声明的框渲染组件,因此未设置的尺寸会显示为 0:实例在预览中看起来会收缩,面板显示高度为 0,即使 Yoga 在运行时能正确布局。这正是该故障的完整表现——一个根元素设置了width: 400,包含 26 px 的标签行和 44 px 的轨道,在世界中会自动调整为 70 px 高,但在画布上却什么也不显示。应声明height: 70。 - 组件引用外的包装
UiEntity也需要同样处理:显式设置与组件根元素尺寸一致的width/height,并保留它们存在的用途所需的边距或位置设置。只带margin/position的包装元素也会同样收缩。 - 由于运行时中固定尺寸的流式子元素布局正常,这两种故障只有在有人打开编辑器时才会显现——而这正是遵守此约定的意义所在。
- 每个
Label都必须在uiTransform中显式声明width和height,堆叠标签的每个容器也一样。 这是上述编辑器画布规则在运行时的对应要求,影响的是另一个方面:文本的固有尺寸取决于引擎。Bevy explorer 会测量渲染后的文本,并将其高度反馈给弹性布局;Unity explorer 会将未设置尺寸的文本维度视为约 0,但仍会在零高度节点上绘制字形。因此在 Unity 上,堆叠的标签会重叠,任何根据文本子元素自动调整尺寸的父元素都会收缩到只剩内边距。通过并排截图在世界中验证过:一个 720 px 的对话框中,两个标签都设置了width: '100%'、textWrap="wrap",但没有设置height,且所在面板也没有设置height;它在 Bevy 上正常,在 Unity 上却被挤扁——两个标签绘制在彼此之上,面板收缩到只剩内边距和按钮。该场景中的修复方式是:面板设置height: 210,名称标签设置height: 30,换行的正文标签设置height: 60,按钮标签设置width: '100%', height: '100%'。换行的多行标签需要根据行数设置高度(fontSize: 20时两行 → 60);填满固定父元素的标签使用100%/100%。请注意,如今这三种环境的表现已一致——只有明确设置每个框的尺寸,编辑器画布、Bevy 和 Unity 才会保持一致;和 emoji 规则一样,这也取决于引擎,因此在一个 explorer 中预览正确并不能证明什么。 - 所有绑定的尺寸或位置都必须是纯 px
number。 值中不得包含算术运算,也不得使用百分比字符串。未绑定键中的静态百分比字面量没有问题,而且是实现流式布局的最佳工具。 - 设计时要考虑两个默认虚拟画布:桌面端
1920x1080,移动端1600x720。移动端画布高度少 33%,因此在桌面端能容纳的高堆叠布局可能会超出手机屏幕。应锚定边缘,并使用弹性布局/百分比字面量处理需要自适应的方向,而不是针对某个高度计算绝对偏移。 - 触控目标:每个可按元素在虚拟画布上都应有至少约 48 px 的实际框——绝不要依赖与文本大小相同的点击区域。
- 文本:正文的
fontSize应 ≥ 16;移动端玩家需要在移动时阅读的内容最好 ≥ 20。任何可能变长的标签都要设置textWrap="wrap"和明确的宽度。 - 任何文本值中都不得包含 emoji——包括
Label/Button的value、Input的placeholder、Dropdown的选项,以及绑定到文本值的任何状态变量。emoji 字形来自 explorer 内置的字体,而不是 SDK;Unity explorer 不附带任何 emoji 字体,因此它们会显示为缺字方框或完全不显示。在世界中验证过:value="✨ Particles"菜单按钮上的闪光符号在 Unity 上消失了。这取决于引擎,因此在一个客户端中能显示并不能证明什么。若要呈现图示功能提示,可在标签旁添加一个带有uiBackground={{ texture: { src: 'images/icon.png' } }}的小型UiEntity——texture.src是一等绑定属性,因此图标可在面板中编辑和替换,而烘焙到字符串里的 emoji 无法做到这一点。 - 如果移动端布局在结构上确实不同(例如用底部面板代替侧边栏、减少可见列数),应使用平台变体,而不是不断缩小桌面布局直到适配。
- 悬停层在触屏上不起作用:绝不要让
hover层成为唯一的功能提示方式或唯一的读值方式。
编辑器的移动端预览(creator-hub 379aa27e)
将画布切换到移动端后,UI 会显示在一个带横向刘海的手机机身内,尺寸为 1600x720——与 react-ecs 在移动端使用的虚拟画布相同,因此 fitScale 为 1,不会出现黑边。桌面端仍为 1920x1080。还可选择其他屏幕(移动端:1600x720 DCL 参考尺寸、2340x1080 19.5:9、2048x1536 4:3;桌面端:1920x1080、2560x1080 超宽屏、1440x900),选择非默认预设时会出现黑边。
画布会绘制两个参考区域,与当前使用的渲染器 screenInset 相匹配;还可切换显示一组HUD 参考控件(摇杆、跳跃、F/E、表情、个人资料、聊天、指南针、计数器、指针),这些控件以不可交互的圆盘形式绘制。HUD 切换按钮位于画布缩放控件中,是一个游戏手柄图标(仅限移动端);在安全区域模式下默认显示,在全屏模式下默认隐藏。
编写内容时,这意味着:
interactable仅排除左侧 HUD 栏。 它的右边缘与设备区域相接,因此右下角操作控件组(跳跃 / E / F / 指针)按设计位于 interactable 区域内——即使设置了screenInset: 'interactable',锚定在右下角的元素仍会与这些按钮争用空间。请借助 HUD 参考线避开它们。- 超出安全区域轮廓的内容会显示出来,而不会被裁切——这是有意为之,因为游戏内也是如此(
ScreenInsetArea/InteractableArea未设置overflow)。内容溢出轮廓意味着位置需要调整,而非渲染异常。 - 预览中的内缩数值只是静态近似值(iPhone 14 Pro 横屏:设备区域宽度约为 86%,交互区域宽度约为 65%,顶部 / 底部边距为 6%),并非实时数值。游戏内的 explorer 会逐帧报告实际的
UiCanvasInformation.screenInsetArea/interactableArea。无论哪种情况,生成的 react-ecs 源码都完全相同——预览效果不正确绝不意味着代码生成有问题,预览效果正确也不能证明布局在特定设备上正确。
自检清单
对你编写或改造的 每个 .tsx 文件运行此检查。违反任一项都会导致编辑器静默失败。
- 文件路径为
src/ui/<PascalCaseName>.tsx,文件名与导出的组件名一致,第一行是/** @jsx ReactEcs.createElement */,且只导出一个组件。 - 每个 JSX 元素名称都是
UiEntity、Label、Input、Dropdown、Button,或src/ui/中其他文件导出的组件。 - 不得使用
{cond && <X/>}或cond ? <A/> : <B/>——usePlatform()变体除外,其分支都必须是单个元素或null。 - 不得使用
.map()、循环或由数组构建子元素。 - 任何位置都不得出现
{/* JSX comments */};注释应使用 JSX 外部的//。 uiTransform/uiBackground中的每个值都必须是字面值,或裸state.x/props.x引用。使用 grep 搜索文件中的?、+、*、Math.、(以及这些对象中的任何裸标识符——每一项都会冻结节点。- 所有文本都必须放在
Label/Button的value属性上;UiEntity不得使用uiText属性包。 - 属性中的模板字面量只允许插入裸引用。
- 任意元素上唯一允许的展开属性是一个
{...someUseInteractionConst}。 - 可见性必须通过
useInteraction的active层将display设为'none';悬停/按压状态使用hover/press层。 - 任何
100%×100%的元素上都不得有指针处理程序,也不得展开{...useInteraction}。 检查每个尺寸为width: '100%', height: '100%'的元素(以及每个未明确指定尺寸但占满屏幕的元素),确认其没有onMouse*属性、展开属性或pointerFilter: 'block'。展开属性也算在内,因为useInteraction始终会返回全部四个监听器,即使它仅用作可见性开关也一样——任何监听器都会让元素捕获整个矩形区域内的点击,导致场景中的其他点击全部失效。开关和处理程序应放在面板/按钮上;全屏包装元素应仅用于定位。唯一例外是用于拦截点击的模态背景,以及拖动释放捕获器;这两者在空闲时都必须关闭。 - 必须包含
export interface State+export const state: State;每个属性都必须是number、string、boolean、string[]或结构为{ r, g, b, a }的颜色。 - 声明的 props 必须是内联对象类型,且成员只能是可选的
number/string/boolean/ 回调。 - 每个处理程序都必须是一个带有
/** @ui-action */标注的函数,参数为({ state, props, value }: UiAction),并通过 thunk 接入。 - 绑定的尺寸/位置必须是 px 数值;绑定值中不得使用百分比字符串。
- 组件的根元素必须明确声明
width和height(px 数值或百分比字面量)——不得根据子元素自动调整尺寸。组件引用周围的每个包装UiEntity也必须明确声明这两项,并与该组件的根元素尺寸一致。未设置的尺寸在编辑器画布上会渲染为 0,但运行时布局仍会正确。 src/ui/中任何位置都不得使用时钟、Date.now()、setTimeout、缓动、舍入或字符串格式化——这些逻辑都应放在驱动程序中。src/ui/index.tsx必须与生成的结构完全一致,每个根元素都要由ScreenInsetArea包裹,除非明确要求使用全画布控制。- 移动端检查:布局在 1600x720 画布上仍可正常显示,触控目标不小于约 48 px,且不得有仅悬停时才可用的交互提示。
- 你编写的任何位置都不得调用
TouchScreenControls——移动端控件由 MobileHUD 面板提供,位于src/mobile-hud.ts。使用 grep 检查场景中的TouchScreenControls,并确认唯一的匹配项位于该生成文件中。
参考资料
{baseDir}/references/interaction-helper.md—src/ui/interaction.tsx和src/ui/platform.tsx的原始内容(任何非编辑器创建的场景都必须包含)。{baseDir}/references/component-template.md— 一个可编辑的最简组件、一个功能完整的组件(包含绑定、操作、悬停、门控、平台变体),以及组合后的屏幕和聚合器。{baseDir}/references/driver-pattern.md— 驱动器示例:缓动打开/关闭、格式化计时器、双变量退出门控、单次动画、命名约定。{baseDir}/references/adapting-coded-ui.md— 移植生产环境中代码编写的 UI 的前后对照方案,以及需要告知用户哪些内容无法移植。{baseDir}/references/drag-slider.md— 在此契约下经过实际环境验证的拖动滑块示例:可复用的混合式点击步进 + 拖动组件、始终存在的门控释放捕获器,以及驱动器的拖动部分。任何可编辑 UI 中包含滑块、拖动条或拖动手柄时,都要阅读此文档。