mcpField
E
editable-ui

editable-ui

github.com/decentraland开发4107 次安装1 天前更新
代码仓库

关于 editable-ui

这项技能指导生成或改造 Creator Hub UI 代码,使可视化编辑器能够解析和修改界面。重点说明保持场景 UI 可编辑的 React-ECS 代码约定。

主要功能

  1. 01编辑器可读的 JSX 规则
  2. 02组件与文件布局指南
  3. 03改造现有 UI 的模式
  4. 04布局与安全区行为
  5. 05代码自检指南

适用场景

  1. 01为 Creator Hub 可视化编辑器创建 UI
  2. 02改造现有 React-ECS 界面
  3. 03排查界面只读或显示为不透明块

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/decentraland/sdk-skills --skill editable-ui

安装后在 Claude Code 中输入/editable-ui确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
adapting-coded-ui.md
component-template.md
drag-slider.md
driver-pattern.md
interaction-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 FlowpositionType('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
  1. 每个组件单独放在一个文件中:src/ui/<ComponentName>.tsx。文件名必须是有效的 PascalCase 标识符,并且与导出的函数名一致——文件名不符合此格式时,编辑器会忽略该文件。
  2. 根据 {baseDir}/references/interaction-helper.md 原样创建 src/ui/interaction.tsx,如果使用平台变体,还要创建 src/ui/platform.tsx。这两个文件名是保留的辅助文件名;编辑器会自动生成它们,并且不会将它们列为 UI。
  3. 按照下文的组件契约编写每个组件。从 {baseDir}/references/component-template.md 开始。
  4. 按照生成的确切结构编写 src/ui/index.tsx(参见聚合文件),并在 src/index.ts 的 main() 中调用 setupUi()。
  5. 所有时钟、缓动、计时器、格式化器和状态机都放在 src/ui/ 外的普通 .ts 文件中——参见驱动程序模式。
  6. 对你创建的每个文件运行本文末尾的自检清单。
B. 适配现有的编码 UI
  1. 将 UI 拆分为 src/ui/ 下的多个文件,每个组件一个文件;将所有非 JSX 逻辑(辅助函数、时钟、数学运算、数据)移至 src/ui/ 外的 .ts 文件中。
  2. 按以下顺序机械地消除五个阻碍——每项的前后代码示例请参见 {baseDir}/references/adapting-coded-ui.md:
    • .map() / 循环 → 将每一项展开为同级元素。
    • {cond && <X/>} 和 cond ? <A/> : <B/> → 使用 useInteraction 的 active 层,并设置 display: 'none'(唯一例外是桌面端 / 移动端平台变体,它是受支持的结构)。将每个条件门放在要隐藏的元素上,而不要放在包裹全屏内容的容器上——展开内容会携带指针处理器(参见交互层)。
    • 计算得到的样式值(算术运算、Math.*、函数调用、字符串拼接、三元表达式、共享主题标识符)→ 为每个派生值使用一个由驱动程序维护的绑定状态变量,或使用内联字面值。
    • 内联插值文本 → 使用混合文本片段绑定,并将格式化逻辑移至驱动程序中。
    • 手动跟踪的悬停 / 按下布尔值 → 使用 hover / press 层。
  3. 将在渲染时计算样式值的补间 / 动画代码替换为驱动程序,由它将最终值缓动至绑定状态变量。
  4. 所有尺寸和位置值都必须是纯 px 数字,并且每个组件根节点和组件引用包装器都必须显式设置 width / height——编码 UI 通常会根据子元素自动调整容器大小,这种行为在移植后仍会保留,导致容器在画布上的高度变为 0(参见尺寸和移动端)。
  5. 重新运行自检清单。

适配后的文件预计会比原文件更大:展开循环和变体是实现可编辑性的代价。请向用户说明这一取舍,而不是悄悄地只完成部分移植。

文件布局

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(参见尺寸与移动端)。
  • Input props:placeholder、value、color、placeholderColor、disabled、textAlign、font、fontSize。
  • Dropdown props: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 文件运行此检查。违反任一项都会导致编辑器静默失败。

  1. 文件路径为 src/ui/<PascalCaseName>.tsx,文件名与导出的组件名一致,第一行是 /** @jsx ReactEcs.createElement */,且只导出一个组件。
  2. 每个 JSX 元素名称都是 UiEntity、Label、Input、Dropdown、Button,或 src/ui/ 中其他文件导出的组件。
  3. 不得使用 {cond && <X/>} 或 cond ? <A/> : <B/>——usePlatform() 变体除外,其分支都必须是单个元素或 null。
  4. 不得使用 .map()、循环或由数组构建子元素。
  5. 任何位置都不得出现 {/* JSX comments */};注释应使用 JSX 外部的 //。
  6. uiTransform / uiBackground 中的每个值都必须是字面值,或裸 state.x / props.x 引用。使用 grep 搜索文件中的 ?、+、*、Math.、( 以及这些对象中的任何裸标识符——每一项都会冻结节点。
  7. 所有文本都必须放在 Label / Button 的 value 属性上;UiEntity 不得使用 uiText 属性包。
  8. 属性中的模板字面量只允许插入裸引用。
  9. 任意元素上唯一允许的展开属性是一个 {...someUseInteractionConst}。
  10. 可见性必须通过 useInteraction 的 active 层将 display 设为 'none';悬停/按压状态使用 hover / press 层。
  11. 任何 100%×100% 的元素上都不得有指针处理程序,也不得展开 {...useInteraction}。 检查每个尺寸为 width: '100%', height: '100%' 的元素(以及每个未明确指定尺寸但占满屏幕的元素),确认其没有 onMouse* 属性、展开属性或 pointerFilter: 'block'。展开属性也算在内,因为 useInteraction 始终会返回全部四个监听器,即使它仅用作可见性开关也一样——任何监听器都会让元素捕获整个矩形区域内的点击,导致场景中的其他点击全部失效。开关和处理程序应放在面板/按钮上;全屏包装元素应仅用于定位。唯一例外是用于拦截点击的模态背景,以及拖动释放捕获器;这两者在空闲时都必须关闭。
  12. 必须包含 export interface State + export const state: State;每个属性都必须是 number、string、boolean、string[] 或结构为 { r, g, b, a } 的颜色。
  13. 声明的 props 必须是内联对象类型,且成员只能是可选的 number / string / boolean / 回调。
  14. 每个处理程序都必须是一个带有 /** @ui-action */ 标注的函数,参数为 ({ state, props, value }: UiAction),并通过 thunk 接入。
  15. 绑定的尺寸/位置必须是 px 数值;绑定值中不得使用百分比字符串。
  16. 组件的根元素必须明确声明 width 和 height(px 数值或百分比字面量)——不得根据子元素自动调整尺寸。组件引用周围的每个包装 UiEntity 也必须明确声明这两项,并与该组件的根元素尺寸一致。未设置的尺寸在编辑器画布上会渲染为 0,但运行时布局仍会正确。
  17. src/ui/ 中任何位置都不得使用时钟、Date.now()、setTimeout、缓动、舍入或字符串格式化——这些逻辑都应放在驱动程序中。
  18. src/ui/index.tsx 必须与生成的结构完全一致,每个根元素都要由 ScreenInsetArea 包裹,除非明确要求使用全画布控制。
  19. 移动端检查:布局在 1600x720 画布上仍可正常显示,触控目标不小于约 48 px,且不得有仅悬停时才可用的交互提示。
  20. 你编写的任何位置都不得调用 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 中包含滑块、拖动条或拖动手柄时,都要阅读此文档。

评论

支持 Markdown