关于 roblox-ui
Roblox UI 是一项介绍 Roblox UI 引擎并指导使用 Fusion 0.3 构建界面的技能。它提供引擎参考和设计建议,同时要求遵循项目已有的 UI 框架与模式。
主要功能
- 01Roblox UI 引擎参考
- 02Fusion 0.3 实例模式
- 03Fusion 状态与动画指导
- 04用户体验、布局和无障碍指南
适用场景
- 01构建新的 Roblox UI 组件
- 02选择布局与输入模式
- 03使用 Fusion 实例和状态
- 04应用响应式与无障碍界面建议
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ afrxo/ roblox-agent-skills --skill roblox-ui安装后在 Claude Code 中输入/roblox-ui确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/.gitkeepfusion-animation.luaufusion-button.luaufusion-counter.luaufusion-form.luaufusion-list.luaufusion-story.luaureferences/design-guidelines.mdfusion-instances.mdfusion-state.mdroblox-ui-primitives.md详细介绍
Roblox UI
Roblox UI 在引擎层面的工作原理,以及如何使用 Fusion 0.3 构建 Roblox UI。
来源:create.roblox.com/docs/ui(引擎原语)、elttob.uk/Fusion/0.3(Fusion 文档)、github.com/dphfox/Fusion(源代码)。
深入了解:
references/roblox-ui-primitives.md— 完整的 GuiObject / 布局 / 输入参考references/design-guidelines.md— 与框架无关的 UX、间距 / 字体 / 颜色比例、状态、动效、无障碍设计references/fusion-instances.md—New/Hydrate/[Children]/[OnEvent]/[OnChange]/[Out]/[Attribute]/[Child]参考references/fusion-state.md—Value、Computed、Observer、peek、use、动画原语
集成策略
本技能介绍的是推荐模式;它不授权重写现有 UI。将规则应用于新组件,以及用户明确要求修改的内容。**遵循项目选定的 UI 框架。**如果代码库使用 Roact,不要在未收到要求时迁移到 Fusion。如果现有组件混用了偏移量和比例值,就沿用这种混合方式。如果已经有主题模块,就使用它——不要再创建一个并行模块。设计指南是建议,而非强制要求。
完整规则:../../shared/integration-policy.md。
Roblox UI 引擎 — 心智模型
每个 Roblox UI 都是由 GuiObject 构成的树,并以 UI 容器为父级。容器决定 UI 的渲染位置;GuiObject 决定显示的内容。
容器(UI 的渲染位置)
| 容器 | 显示位置 | 所属位置 |
|---|---|---|
ScreenGui | 玩家屏幕上的平面 2D 覆盖层 | Players[X].PlayerGui(每个角色都会从 StarterGui 复制过来) |
SurfaceGui | 3D 世界中 BasePart 的某个面上(类似广告牌) | BasePart,或设置了 Adornee 的 StarterGui |
BillboardGui | 悬浮在 3D 空间中,始终朝向摄像机,可选设置 MaxDistance | BasePart / Attachment,或设置了 Adornee 的 StarterGui |
值得了解的 ScreenGui 属性:
DisplayOrder— 同级ScreenGui之间的整数排序值(值越大,显示位置越靠上)。IgnoreGuiInset— 设为false(默认值)时,顶部栏预留区域会使可见区域偏移。全屏覆盖层(加载屏幕、模态对话框)应设为true。ResetOnSpawn— 设为true时,每次重生都会销毁 GUI,并从StarterGui重新克隆。对于持久状态(HUD、货币显示)应设为false。ZIndexBehavior—Sibling(默认值;ZIndex 仅在同一父级内排序)与Global(ZIndex 在整棵树中排序)二选一。几乎总是应保留Sibling。
SurfaceGui 的额外属性:
Face— 显示内容的部件表面。SizingMode—FixedSize(直接使用CanvasSize)与PixelsPerStud二选一。AlwaysOnTop— 即使被遮挡物挡住也进行渲染。
BillboardGui 的额外属性:
- 这里的
Size以 stud(UDim)而不是像素为单位——会随距离缩放。 MaxDistance— 超出该距离后隐藏 GUI。StudsOffset/StudsOffsetWorldSpace— 相对于附着对象设置 3D 位置。
GuiObject 核心属性
每个 GuiObject(Frame、TextLabel、ImageButton 等)都包含:
Size: UDim2—{ScaleX, OffsetX}, {ScaleY, OffsetY}。Scale 是相对于父对象的比例;Offset 是像素值。两者会相加。Position: UDim2— 形状相同;表示 AnchorPoint 在父对象内的偏移。AnchorPoint: Vector2—(0..1, 0..1)。位置和缩放的原点。默认值(0,0)= 左上角。(0.5, 0.5)= 居中(使用Position = UDim2.fromScale(0.5, 0.5)可在父对象中居中)。AbsoluteSize: Vector2— 只读,根据当前帧以像素为单位计算。AbsolutePosition: Vector2— 只读,根据当前帧以像素为单位计算。AutomaticSize: Enum.AutomaticSize—None/X/Y/XY。调整对象大小以适应其内容。与自动调整轴上的Size设置为(0,0,0,0)配合使用。ClipsDescendants: boolean— 将子对象裁剪到此对象的边界内。Visible: boolean— 显示/隐藏。隐藏的对象不会接收输入。Active: boolean— 对普通 Frame 而言,触发MouseButton1Down这类事件时必须设为true;按钮(TextButton/ImageButton)始终处于活动状态。ZIndex: number— 同级对象中的渲染顺序(值越高越靠前)。LayoutOrder: number— 布局结构(UIListLayout等)中的排序顺序。Selectable: boolean— 是否符合游戏手柄选择条件。
UDim2 速查表
UDim2.new(sx, ox, sy, oy) -- explicit
UDim2.fromOffset(px, py) -- pure pixel: (0, px, 0, py)
UDim2.fromScale(sx, sy) -- pure scale: (sx, 0, sy, 0)
UDim2.new(0.5, 0, 0.5, 0) -- center if AnchorPoint is (0.5, 0.5)
UDim2.new(1, -20, 1, -20) -- fill parent minus 10px margin on each side
规则: 有意混用 scale 和 offset。纯 scale = 布局随屏幕尺寸灵活调整。纯 offset = 像素级精确,但在手机上容易错位。大多数生产环境中的 UI 会对外层容器使用 scale,对内部内容使用 offset。
布局结构(由父对象控制位置)
将以下结构之一作为子对象添加,即可接管同级对象的布局。同级对象的 Position 会被忽略;具体布局下,Size 也可能被忽略。
| 类 | 功能 |
|---|---|
UIListLayout | 沿某个轴堆叠子对象(FillDirection = Vertical/Horizontal)。遵循 LayoutOrder、Padding、HorizontalAlignment / VerticalAlignment 和 SortOrder。 |
UIGridLayout | 使用 CellSize 和 CellPadding 创建网格。 |
UIPageLayout | 一次显示一个子对象;使用 :JumpTo(child) / :Next() / :Previous() 切换。常用于选项卡/向导。 |
UITableLayout | 各行高度相同;每行各列宽度相同。不如 UIGridLayout 常用。 |
-- UIListLayout — vertical stack with 8px gap, sorted by LayoutOrder
local container = Instance.new("Frame")
local layout = Instance.new("UIListLayout", container)
layout.FillDirection = Enum.FillDirection.Vertical
layout.Padding = UDim.new(0, 8)
layout.SortOrder = Enum.SortOrder.LayoutOrder
修改器和约束(单实例调整)
将以下对象之一作为要修改的 GuiObject 的子对象添加:
| 类 | 效果 |
|---|---|
UICorner | 圆角。CornerRadius: UDim。 |
UIPadding | 子对象的内边距(上/下/左/右 UDim)。不改变自身大小;会影响子对象布局。 |
UIStroke | 描边。包含 Thickness、Color、Transparency、ApplyStrokeMode = Border / Contextual、StrokeSizingMode。取代旧的边框属性。将 StrokeSizingMode = Enum.StrokeSizingMode.ScaledSize,使粗细随父对象大小缩放,从而在不同屏幕尺寸下保持描边比例。默认的 FixedSize 会保持像素粗细不变,不受 UI 缩放影响。 |
UIScale | 对 GuiObject 的有效大小及其后代对象应用倍数。适用于全局缩放。 |
UIAspectRatioConstraint | 锁定宽高比。包含 AspectRatio、AspectType = ScaleWithParentSize / FitWithinMaxSize、DominantAxis。 |
UISizeConstraint | 像素大小的最小值/最大值。 |
UITextSizeConstraint | 对使用 TextScaled 的文本设置最小/最大字号。 |
UIGradient | 线性渐变;通过 Color: ColorSequence + Transparency: NumberSequence + Rotation 进行着色。会影响后代对象。 |
UIFlexItem | 为启用 flex 的 UIListLayout 中的子对象设置 flex 权重。 |
常见 GuiObjects
Frame— 普通矩形。BackgroundColor3、BackgroundTransparency、BorderSizePixel = 0(现代 UI 始终将其设为 0)。ScrollingFrame— 可滚动的裁剪框架。设置CanvasSize: UDim2(或设置AutomaticCanvasSize = X/Y/XY,自动适应内容)。ScrollBarImageColor3和ScrollBarThickness用于设置滚动条样式。CanvasGroup— 将子级组合起来进行合成(作为单个图层进行 Alpha 混合;无需逐个淡出子级,即可让整个组透明)。TextLabel/TextButton/TextBox— 文本。属性:Text、Font(旧版)或FontFace: Font(现代版)、TextSize、TextColor3、TextWrapped、TextScaled(自动调整大小以适应内容;搭配UITextSizeConstraint使用)、RichText(解析<b>、<i>、<font color="...">等)、TextXAlignment/TextYAlignment、LineHeight。TextBox其他属性:PlaceholderText、PlaceholderColor3、ClearTextOnFocus、MultiLine、:CaptureFocus()/:ReleaseFocus()。事件:Focused、FocusLost(enterPressed, inputThatCausedFocusLoss)。ImageLabel/ImageButton—Image: Content、ImageColor3、ImageTransparency、ScaleType(Stretch/Tile/Slice/Fit/Crop)、SliceCenter: Rect(九宫格切片)、TileSize: UDim2(用于平铺模式)。ViewportFrame— 在 UI 元素内渲染 3D 场景。拥有自己的Camera和WorldModel。适用于物品预览、角色卡片。VideoFrame— 播放rbxasset://视频。适用场景有限;建议使用游戏世界中的部件视频。
输入
对于按钮(TextButton / ImageButton):
button.Activated:Connect(function(inputObject, numClicks)
-- Fires for: left mouse click, touch tap, gamepad A, Enter when focused.
end)
Activated 是默认的正确选择——它统一处理鼠标、触摸和游戏手柄输入。仅在需要更底层的事件时使用:
MouseButton1Down/MouseButton1Up/MouseButton2Down/ 等 — 仅限鼠标。TouchTap/TouchPan/TouchPinch— 仅限触摸。
对于任意 GuiObjects(先设置 Active = true):
InputBegan(input)/InputChanged(input)/InputEnded(input)— 接收包含UserInputType(Mouse / Touch / Gamepad / Keyboard)以及KeyCode/Position/Delta的InputObject。
对于全局输入(摄像机、快捷键等),不要在 GuiObject 上添加监听器——应使用:
UserInputService— 全局信号:InputBegan、InputChanged、InputEnded、JumpRequest,以及游戏手柄辅助功能。始终会提供第二个参数gameProcessed: boolean——若为true,表示引擎已处理该输入(例如用户在 TextBox 中输入,或点击了按钮)。除非另有原因,否则当gameProcessed为true时应跳过处理。ContextActionService— 通过优先级和运行时重新绑定,将命名操作一次绑定到多个输入。更适合可重新绑定的快捷键。
渲染顺序
- 不同
ScreenGui之间:DisplayOrder(值越大,越靠前)。 - 同一
ScreenGui内(同级元素):ZIndex(值越大,越靠前)。默认行为为Sibling——仅在同一父级内排序。 - 布局内部:
LayoutOrder(值越小,在布局方向上越靠前)。
ScreenGui.ZIndexBehavior = Global 会让 ZIndex 在整个树中排序——几乎从不需要;通常说明正在用变通方案弥补父级设置不正确的问题。
顶栏偏移和安全区域
Roblox 会在屏幕顶部预留一块区域,用于聊天、排行榜和菜单按钮。默认情况下,每个 ScreenGui 的起始位置都会偏移到该区域下方。
- 设置
ScreenGui.IgnoreGuiInset = true可填满整个屏幕(加载画面、全屏模态 UI、启动画面)。 - 在 UI 中间区域放置元素时,
GuiService:GetGuiInset()会返回描述预留偏移的(topLeft: Vector2, bottomRight: Vector2)——手动定位时很有用。 - 在 iOS 和 Android 平台上,刘海和摄像头挖孔区域也包含在偏移中。
值得了解的注意事项与“技巧”
- 创建时,
AbsoluteSize/AbsolutePosition会滞后 1 帧。 新设为父级的 Frame 在首次布局计算前会返回(0, 0)。测量前等待一次RunService.Heartbeat,或使用:GetPropertyChangedSignal("AbsoluteSize")进行响应。 AutomaticSize会形成循环。 如果父级设置了AutomaticSize = X,而子级设置了Size = UDim2.fromScale(1, 0),就形成了循环(父级测量子级,子级又需要父级的 100%)。其行为由实现决定;请避免这种情况。- 居中简写:
AnchorPoint = Vector2.new(0.5, 0.5)+Position = UDim2.fromScale(0.5, 0.5)。其他做法都只是在和系统较劲。 - 锁定宽高比的容器: 在
Size仅使用缩放值的 Frame 上设置UIAspectRatioConstraint,例如UDim2.fromScale(0.8, 1)+AspectRatio = 16/9。容器会填满 80% 的宽度,而高度则为 80% 的宽度 / 宽高比。 - 在任意父级内创建完美正方形: 设置
Size = UDim2.fromScale(1, 1),并添加UIAspectRatioConstraint,其中AspectRatio = 1、AspectType = FitWithinMaxSize、DominantAxis = Width。Frame 会在较短的轴上填满父级,同时保持正方形——适用于任何形状的父级(宽、高或动态调整大小)。 - 在手机上实现像素级精准布局: 使用
Offset+UISizeConstraint。纯缩放 UI 在小屏幕上的渲染尺寸难以预测。 TextScaled+UITextSizeConstraint可将自动适配的文本尺寸限制在最小值和最大值之间——没有此约束时,父级高度很小时,文本会缩至 1px。- 模拟阴影: 使用透明度较低、视觉上带偏移的
UIStroke,或叠放设置了ScaleType = Slice的ImageLabel,并使用九宫格阴影 PNG。 - 带图片的圆角按钮: 使用
ImageButton+UICorner+ClipsDescendants,或对九宫格圆角素材使用ScaleType = Slice——UICorner是现代做法。 - 模态对话框: 创建一个全屏
Frame,设置BackgroundTransparency = 0.5和较高的ZIndex。设置Active = true,使其拦截点击。对其中的 TextBox / 按钮设置Modal = true,以锁定游戏手柄 / 键盘导航。 - 拖动: 相较于
InputBegan/Changed/Ended链式事件,UserInputService更可靠,因为光标离开源 GuiObject 时,追踪不会中断。通过input.Delta(鼠标)或input.Position(触摸)追踪变化量。 - 选择组(游戏手柄导航): 使用
GuiService.SelectedObject和每个 GuiObject 上的NextSelectionUp/Down/Left/Right配置焦点图。对每个可聚焦项目设置Selectable = true。 - 在自定义 UI 流程中隐藏光标: 设置
UserInputService.MouseIconEnabled = false。清理时恢复。 - 测量文本:
TextService:GetTextSize(text, size, font, frameSize) -> Vector2。可在调整父级大小前使用。可以的话,优先使用AutomaticSize。 - 现代字体:
FontFace是一种Font数据类型(Font.new(family, weight, style))。旧版Font: Enum.Font仍然可用,但字体系列枚举已冻结。新代码优先使用FontFace。
完整的基础元素参考:references/roblox-ui-primitives.md。
设计指南
与框架无关。适用于 Fusion、Vide、React Lua 或原始的 Instance.new。美学目标:中性、简洁的产品风格(可参考 Discord / Notion / 现代游戏启动器——适用于大多数场景,不拘泥于某一种艺术风格)。注重用户体验和一致性,而非标新立异。
原则
- 层级胜过装饰。 大小、字重和对比度承载着含义。如果按钮与旁边的禁用标签大小和颜色相同,这就是 UX 问题。
- 一致性胜过巧思。 相同操作 → 处处采用相同的视觉样式。处处保持相同的间距节奏。选定一套尺度并坚持使用。
- 每个操作都要有反馈。 UI 不能毫无响应。点击 → 状态变化。加载 → 旋转指示器 / 骨架屏。出错 → 显示消息。内容为空 → 提供引导。
- 每个屏幕只有一个视觉焦点。 主要操作应清晰可见,其他内容都应退居其次。如果两个元素争夺同等注意力,结果就是谁也不突出。
- 尊重平台特性。 移动端 = 触控目标。主机 = 游戏手柄导航图。桌面端 = 悬停提示。请使用玩家实际采用的输入方式进行测试。
- 性能就是 UX。 无论外观如何,卡顿的 UI 都会让人觉得它出了问题。避免使用
RunService.Heartbeat重新渲染、每帧生成大型 RGB 渐变,以及会反复重新复制的深层实例树。
间距尺度
选定一个基础单位并按倍数使用。Agent 默认值:
4 8 12 16 24 32 48 64
按比例缩放(相对于父级较短边,约 720–1080 dp):0.005, 0.01, 0.015, 0.02, 0.03, 0.04, 0.06, 0.08。使用 UIPadding + UIListLayout.Padding 设置间距,不要在元素之间使用随意偏移值。
规则: 每个 UIPadding 值、每个布局间距、每个外边距都应采用该尺度中的一级。如果你发现自己在输入 UDim.new(0.017, 0),就说明你偏离了这套尺度。
字体尺度
12 14 16 18 24 32 48
用途:
| 大小 | 用途 |
|---|---|
| 12 | 说明文字、元数据、脚注 |
| 14 | 小号正文、次要标签 |
| 16 | 正文、按钮文字(默认) |
| 18 | 强调正文、卡片标题 |
| 24 | 分区标题 |
| 32 | 页面标题 |
| 48 | 仅用于展示文字 / 主视觉 |
整个 UI 使用一个字体系列。最多使用两个(正文 + 展示文字)。使用 FontFace = Font.fromName(...),并在整个项目中复用同一个 Font。使用 TextScaled = true 时,始终配合使用此尺度中的 UITextSizeConstraint { MaxTextSize = N }。
颜色
采用语义角色,而非直接使用原始颜色:
Surface— 基础背景。SurfaceElevated— 卡片、模态框(深色 UI 中比 Surface 浅一个色阶)。Primary— 品牌的主要操作。OnPrimary— Primary 上的文字 / 图标。Text— 主要文字。TextMuted— 次要文字。Border— 分隔线、描边。Success/Warning/Danger— 语义反馈。
集中定义在一个模块 / 表中。不要在调用处直接写 Color3.fromRGB(0, 120, 255),而应引用 theme.Primary。
对比度: WCAG AA 标准要求正文文字达到 4.5:1,大号文字(18 及以上)达到 3:1。请测试调色板;许多“好看”的搭配都无法达标。
避免:
- 在 UI 表面使用彩虹渐变。
- 仅靠颜色传达信息(红色错误还需要图标 + 标签;玩家中也有色盲人士)。
- 在纯白背景上使用纯黑
Color3.new(0, 0, 0)文字,观感过于刺眼;可使用类似(0.1, 0.1, 0.12)的颜色。
层级与布局
- 将相关内容分组。 使用
UIListLayout+ 间距尺度。留白是传达“这些内容属于一组” / “这些内容不属于一组”最简单的方式。 - 每个屏幕只有一个 H1。 页面标题放在顶部,字号和字重应与其他内容明显区分。
- 按钮遵循层级: 主要(填充品牌色)→ 次要(中性色描边)→ 第三级(纯文字)。切勿将两个主要按钮并排放置。
- 所有内容都要对齐。 使用
UIListLayout/UIGridLayout,不要逐个设置子元素的位置。文字 / 边缘未对齐会显得粗糙。
状态覆盖(每个交互元素)
每个按钮 / 输入框 / 可选项都需要具备以下状态:
| 状态 | 视觉提示 |
|---|---|
| 默认 | 基础样式 |
| 悬停(鼠标) | 轻微改变色调或突出边框 |
| 按下 | 颜色变深 / 凹陷效果 |
| 聚焦(游戏手柄 / 键盘) | 使用 UIStroke 高亮,不能只靠颜色 |
| 禁用 | 降低对比度,不显示悬停效果;Active = false |
| 加载中 | 旋转指示器 / 骨架屏;锁定后续输入 |
| 错误 | 行内消息 + 图标 |
发布按钮时,至少要实现默认、悬停、按下和禁用状态。
动效
- 时长:大多数过渡为 150–250ms。<100ms 会感觉瞬间完成(适合悬停);>300ms 会显得迟缓。
- 缓动:进入时使用
Quad / Sine Out。退出时使用Quad / Sine In。线性仅用于不确定进度的旋转指示器。 - 交互反馈(悬停、拖动、回弹)使用 Spring。固定时长的过渡(页面切换、淡入/淡出)使用 Tween。
- 始终为影响布局的状态变化添加动画。瞬间切换会让人感到突兀;补间动画能保持连贯。
- 不要仅为装饰而添加动画。动效应传达因果关系。
- 遵循减少动态效果设置:如果平台提供此设置,应予以遵循。
触控目标与输入
- 触控目标最小为 44×44 px(Apple HIG / Material)。即使按钮使用缩放尺寸,也应使用
UISizeConstraint { MinSize = Vector2.new(44, 44) }。 - 桌面端应提供悬停提示。可点击元素(
ImageLabel/Button)使用手形光标(MouseCursor)。 - 明确设置游戏手柄选择图(
NextSelectionUp/Down/Left/Right)。屏幕打开时设置GuiService.SelectedObject。 - 键盘支持:文本框应支持按 Enter 提交,并在适用时支持按 Escape 取消。
响应式布局
- 优先使用缩放尺寸。纯像素 UI 在手机上会出问题。
- 关键面板应使用
UIAspectRatioConstraint,避免被挤压变形。 - 使用
UISizeConstraint设置像素最小值/最大值 — UI 不应缩小到难以阅读。 - 测试极端尺寸:最小的手机屏幕(竖屏,宽 320–375 dp)和最大的桌面屏幕(宽 3840 dp)。
- 不要依赖视口边缘检测来切换布局;应通过适当的约束让同一套 UI 适用于所有设备。检测输入类型(
UserInputService.TouchEnabled、GamepadEnabled)来调整行为,而非布局。
空状态 / 加载状态 / 错误状态
对于每个异步加载数据的界面:
- 空状态 — 提供有用的指引(“当前没有好友在线 — 邀请一位好友”)。不要显示空白矩形。
- 加载状态 — 显示旋转指示器或骨架屏(占位形状)。提交类流程中应锁定输入。
- 错误状态 — 显示行内消息和重试操作。绝不能直接消失。
无障碍
- 对比度:至少符合 WCAG AA 标准。
- 最小文本尺寸:不低于 12pt。正文使用 14pt。
- 不要只依赖颜色:搭配图标、标签或图案。
- 焦点指示器清晰可见:在获得焦点的元素上使用
UIStroke。 - 支持游戏手柄导航:每个交互元素都应能通过选择图访问。
- 闪烁频率不超过 3 Hz(有癫痫风险)。
反模式(设计)
| ❌ | ✅ |
|---|---|
使用神奇数字设置间距(UDim.new(0.017, 0)) | 使用间距刻度中的步长 |
| 在调用位置内联颜色 | 使用主题模块 / 表 |
| 同尺寸的同级元素堆成一堵文字墙 | 通过尺寸 / 字重 / 颜色建立层级 |
| 并排放置两个主按钮 | 一个主按钮 + 一个次要按钮 |
| 每张卡片下都加投影 | 仅在需要凸显前景的元素上使用高度效果 |
| 不同元素分别使用 4px / 8px / 12px / 6px 的圆角 | 最多选用 2 种圆角(例如,按钮使用 UDim.new(0.1, 0),卡片使用 UDim.new(0.04, 0)) |
BorderSizePixel = 1 | 始终设为 0;需要边缘时使用 UIStroke |
| 静默的禁用状态(按钮只是没有反应) | 明显弱化显示 + Active = false + AutoButtonColor = false |
| 状态变化生硬(颜色瞬间切换) | 使用 Tween / Spring 处理过渡 |
| 在手机尺寸设备上只使用缩放 | 将缩放与 UISizeConstraint 最小值结合使用 |
| 只用颜色表示错误(红色边框,没有消息) | 图标 + 标签 + 颜色 |
| 空状态下什么都不显示 | 提供指引 + CTA |
| 使用三种不同字体 | 最多使用一种正文和一种展示字体 |
| 在生产环境 UI 中使用漫画字体级别的字体 | 使用现代无衬线字体(BuilderSans / Inter / SF / Roboto 系列) |
| 为每个元素单独设置动画时长 | 集中管理动效令牌(例如,MOTION_FAST = 0.15) |
每帧通过 RunService.Heartbeat 重建 UI | 由状态驱动;交由框架批处理 |
| 仅在悬停时才显示可点击提示 | 提示应足够明显,无需悬停即可发现 |
| 忽略顶栏内边距 | 根据设计意图明确设置 IgnoreGuiInset |
请参阅 references/design-guidelines.md,其中包含完整指南和代码示例。
不要混用框架
Fusion、Vide 和 React Lua 的响应式模型互不兼容。一个项目只选用一种。将一种框架包装在另一种框架中会破坏生命周期假设并造成泄漏。如果必须互操作(例如,Roact-Fusion 桥接),请先阅读桥接文档;不要自行实现。
| 库 | 思维模型 |
|---|---|
| Fusion | 响应式声明式;基于作用域的生命周期;状态对象 + 计算值 |
| Vide | 轻量级响应式;源 + 效果;仅函数式 API |
| React Lua | React 风格组件;hooks;元素树 |
Fusion 0.3
具有显式作用域生命周期的响应式声明式 UI。
原则
- 每个对象都属于一个作用域。 作用域是存放清理任务的数组。
Fusion.doCleanup(scope)会按相反顺序执行这些任务。没有作用域 = 泄漏。 - 使用状态对象,而非变量。
scope:Value(initial)是可变状态的基本单位。使用peek(stateObj)读取,使用stateObj:set(new)写入。 - Computed = 纯派生。 当任何
use(stateObj)发生变化时,scope:Computed(function(use, scope) ... end)都会重新运行。计算必须立即完成(不能让出执行权)。 - 描述实例,而非构造实例。
scope:New "TextLabel" { ... }会内联声明实例及其属性和子项;Fusion 负责创建、设置默认值、注入数据和清理。 - 每个项目只使用一种框架。 不要与 Vide 或 React Lua 混用。
作用域
作用域是存放清理任务的数组。添加到作用域的内容会在作用域清理时销毁。
local Fusion = require(ReplicatedStorage.Packages.Fusion)
local scoped = Fusion.scoped
local doCleanup = Fusion.doCleanup
local scope = scoped(Fusion) -- scope with all Fusion methods bound
local health = scope:Value(100)
local name = scope:Value("Player")
-- ...later
scope:doCleanup() -- destroys everything in reverse order
scoped(Fusion) 会返回一个作用域,其中附加了每个 Fusion 函数作为方法,可通过 :Method(...) 语法调用,并将作用域本身作为第一个参数。等价写法:
local v = Fusion.Value(scope, 5) -- function form
local v = scope:Value(5) -- method form (preferred)
作用域层级
scope:innerScope()— 父作用域清理时会自动清理的子作用域。用于生命周期较短的 UI(下拉菜单、模态框)。scope:deriveScope()— 具有相同方法但没有自动清理关联的同级作用域。谨慎使用;优先选择innerScope。
local uiScope = scoped(Fusion)
uiScope:insert(
dropdownOpened:Connect(function()
local dropdownScope = uiScope:innerScope() -- auto-cleaned with uiScope
-- build dropdown using dropdownScope...
dropdownScope:insert(
dropdownClosed:Connect(function() dropdownScope:doCleanup() end)
)
end)
)
scope:insert(task1, task2, ...) 会将任务(Connections、Instances、回调)添加到作用域并返回它们。
doCleanup 接受的对象
- 要运行的函数。
- Roblox
Instance(调用:Destroy())。 RBXScriptConnection(调用:Disconnect())。- 带有
:destroy()/:Destroy()/:disconnect()/:Disconnect()的表。 - 其他作用域(嵌套清理)。
状态对象
Value
local health = scope:Value(100)
print(Fusion.peek(health)) -- 100
health:set(75)
print(Fusion.peek(health)) -- 75
peek(stateObj) 读取当前值,不会订阅变化。
Computed
派生状态。回调接收 (use, scope)。use(stateObj) 会读取并订阅该状态;任何被 use 的状态发生变化时,计算属性都会重新运行。
local coins = scope:Value(50)
local price = scope:Value(10)
local remaining = scope:Computed(function(use, scope)
return use(coins) - use(price)
end)
print(peek(remaining)) -- 40
coins:set(25)
print(peek(remaining)) -- 15
规则:
- 计算必须立即完成,不得让出执行权(例如
task.wait、RemoteEvents 等)。 use(x)会读取并订阅;peek(x)只读取。- 第二个
scope参数是一个全新的内部作用域,每次计算属性重新计算时都会自动清理。对于生命周期应与计算过程一致的对象,请使用它:
scope:Computed(function(use, scope)
local current = use(number)
table.insert(scope, function() print("Cleaning up", current) end)
return current * 2
end)
-- The cleanup runs each time `number` changes, before recalculating.
- **优化注意事项:**如果没有观察者,计算属性可能不会重新计算。不要依赖其中的副作用;此类操作请使用
Observer。
Observer
在状态对象发生变化时触发回调。
local health = scope:Value(100)
local healthChanged = scope:Observer(health)
healthChanged:onChange(function()
print("health is now", peek(health))
end)
用于与状态变化关联的副作用(声音、网络、日志记录)——切勿将副作用放在 Computed 中。
peek 和 use
peek(x)— 全局读取,不会订阅。use(x)— 仅可在Computed回调中使用。读取并订阅,以便在状态变化时重新运行。
即使会遮蔽同名变量,也始终将计算属性的第一个参数命名为 use;如有需要,可使用 --!nolint LocalShadow 抑制 Luau 警告。
实例 — New
以内联属性创建实例:
local message = scope:Value("Hello!")
local label = scope:New "TextLabel" {
Name = "Greeting",
Parent = playerGui.ScreenGui,
Text = message, -- state object → live binding
BackgroundTransparency = 1,
Size = UDim2.fromOffset(200, 50),
}
**绑定到属性的状态对象会在 :set() 后的下一帧自动更新实例。**常量只会应用一次。
scope:New "X" { ... } 是 scope:New("X")({ ... }) 的简写。不使用字符串字面量和花括号时,必须采用两次调用的形式。
Fusion 的 New 会应用自身合理的默认值(例如关闭 UI 边框、将背景透明度设为 1、关闭自动颜色)——这与 Instance.new() 的默认值不同。完整列表请参阅 Fusion 源码中的 defaultProps.luau。
scope:Hydrate(existingInstance) { ... } 会将相同的属性逻辑应用到已有的实例。
特殊属性键
scope:New "TextButton" {
Text = "Click me",
[Children] = {
scope:New "UICorner" { CornerRadius = UDim.new(0, 8) },
scope:New "UIPadding" { PaddingLeft = UDim.new(0, 12), PaddingRight = UDim.new(0, 12) },
},
[OnEvent "Activated"] = function(_, numClicks)
print("clicked", numClicks)
end,
[OnChange "AbsoluteSize"] = function(newSize)
print("resized to", newSize)
end,
[Out "Text"] = textState, -- write Text property out to a state object
}
Children、OnEvent、OnChange、Out 直接从 Fusion 导入,而不是通过 scope 导入。
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local OnChange = Fusion.OnChange
local Out = Fusion.Out
**获取 Instance:**Fusion 0.3 没有 Ref 键。scope:New "X" {...} 会返回 Instance——将它赋给局部变量并直接使用:
local frame = scope:New "Frame" { ... }
-- ...elsewhere:
frame.BackgroundColor3 = Color3.new() -- direct mutation; for reactive props, use a Value bound to the prop
较早版本的 Fusion(0.2 及更早版本)有 Ref;0.3 已将其移除。
[Children] 接受:单个 Instance、Instance 数组(可任意嵌套),或包含上述任一内容的状态对象。
完整参考:references/fusion-instances.md。
组件
组件是一个接收 (scope, props) 并返回 Instance 的函数。
type UsedAs<T> = Fusion.UsedAs<T> -- "T or a state object holding T"
local function Button(
scope: Fusion.Scope,
props: {
Text: UsedAs<string>,
OnClick: () -> ()?,
Size: UsedAs<UDim2>?,
LayoutOrder: UsedAs<number>?,
}
): TextButton
return scope:New "TextButton" {
Size = props.Size or UDim2.fromOffset(120, 40),
LayoutOrder = props.LayoutOrder,
BackgroundColor3 = Color3.fromRGB(0, 120, 255),
Text = props.Text,
TextColor3 = Color3.new(1, 1, 1),
TextSize = 18,
[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 6) },
[OnEvent "Activated"] = function() if props.OnClick then props.OnClick() end end,
}
end
return Button
可通过以下任一形式使用:
-- Function form
local btn = Button(scope, { Text = "OK", OnClick = save })
-- Method form (after registering on the scope)
local scope = scoped(Fusion, { Button = Button })
local btn = scope:Button { Text = "OK", OnClick = save }
UsedAs<T> 是“常量或状态对象”的合适属性类型——peek() 和 use() 会透明地处理两者。
可选属性与可空属性
UsedAs<T>? 和 UsedAs<T?> 看起来相似,但含义不同:
-- Optional with a sensible default — caller may omit; component substitutes a default.
Position: UsedAs<UDim2>?
-- Nullable — `nil` is itself a meaningful value the component reacts to
-- (e.g. cleared input, no selection). Caller must pass it; the inner T is what's optional.
Value: UsedAs<string?>
规则:属性有默认值时,选择 UsedAs<T>?。当缺少值本身属于组件状态模型的一部分时,选择 UsedAs<T?>。
类型化作用域
scope 参数可以包含组件所需的方法。有两种模式:
-- Public component: constrain the scope so callers see what's required.
-- typeof(Fusion) covers all built-in constructors; narrow further if you want.
local function Card(
scope: Fusion.Scope<typeof(Fusion)>,
props: { ... }
): Frame
return scope:New "Frame" { ... }
end
-- Implementation-detail helpers: take a plain Fusion.Scope and derive an
-- inner scope with the helpers attached. Callers don't need to know about them.
local function Modal(scope: Fusion.Scope, props: { ... }): Frame
local inner = scope:innerScope { Backdrop = Backdrop, FocusTrap = FocusTrap }
return inner:New "Frame" { ... }
end
规则:不常用的辅助方法优先放入 innerScope。这样可以保持公开签名简洁,并允许调用者传入任何兼容的作用域。
每个文件一个组件
每个组件(Button、Card、Modal)都放在自己的 ModuleScript 中。这样更容易查找,文件也更聚焦,并能鼓励复用。通过在父作用域上注册来组合组件:
local scope = scoped(Fusion, { Button = Button, Card = Card })
local ui = scope:Card { [Children] = scope:Button { Text = "OK" } }
Stories — UI Labs
UI Labs 是一款适用于 Roblox 的 Storybook 风格 Studio 插件。无需运行游戏即可预览组件;文件更改时会热重载;提供沙盒环境,避免测试挂载污染项目状态。
在对单个组件进行视觉迭代、公开控件(文本、颜色、布尔值)供设计评审,或展示不同状态(默认/悬停/禁用/加载),而无需搭建完整游戏场景时,可以使用 stories。
安装
- 插件 — Roblox Marketplace 资产
14293316215(也可以从 GitHub 源码构建)。 - 实用工具包(可选,推荐用于类型化辅助工具):
- Wally:
pepeeltoro41/ui-labs - npm (roblox-ts):
@rbxts/ui-labs
- Wally:
UI Labs 无需该软件包即可运行——该软件包提供 CreateFusionStory 等类型化辅助工具。
文件约定
- Stories — 名称以
.story结尾的ModuleScript(例如Button.story.luau)。UI Labs 会自动发现这些文件。 - Storybooks — 名称以
.storybook结尾的ModuleScript。返回一个表,其中包含:storyRoots(必需)— 要扫描 stories 的Instance数组。name(可选)— 显示名称;默认为模块名称。groupRoots(可选,布尔值)— 为每个storyRoots条目单独创建一个文件夹。
- 没有 storybook 的 stories 会归入 “Unknown Stories”。随着项目扩展,将它们分组整理。
Fusion 0.3 story 结构
return {
fusion = Fusion,
controls = { Visible = true, Label = "Click me" },
story = function(props)
-- props.target : Frame to mount into
-- props.controls : Fusion.Value for each control, reactive
-- props.scope : Fusion 0.3 scope, auto-cleanup on unmount
return props.scope:New "TextButton" {
Parent = props.target,
Size = UDim2.fromScale(1, 1),
Visible = props.controls.Visible,
Text = props.controls.Label,
}
-- No cleanup return needed — props.scope is doCleanup'd on unmount.
end,
}
不使用 props.scope(适用于旧版 Fusion 或手动清理)时,story 函数必须返回以下其中一项:
- 一个 Roblox
Instance— 卸载时销毁,或 - 一个
() -> ()清理函数 — 卸载时调用。
控件
controls 表中的每个键都会变成一个公开在 props.controls.<name> 上的 Fusion.Value。直接绑定到实例属性,即可在设计师拖动控制面板时实时更新——无需手动使用 Observer。
基本类型控件(string、number、boolean、Color3、EnumItem 等)会根据默认值推断类型。进阶控件(滑块范围、选项下拉菜单)由实用工具包提供——参见 /docs/controls。
反模式
- 在
props.target之外挂载 — UI Labs 无法将其置于沙盒中或卸载;它会在重新加载后继续存在。 - 在 story 中创建新的
scoped(Fusion)— 使用props.scope,以便卸载时彻底清理。 - 在 story 文件中导入真实游戏状态(
Players.LocalPlayer、RemoteEvents)— stories 应使用模拟输入运行;通过controls传递值。 - 不使用
props.scope却返回nil— 卸载时会泄漏实例。
参见 examples/fusion-story.luau。
常见错误
| ❌ | ✅ |
|---|---|
| 创建 Fusion 对象时不使用作用域 | 始终先使用 scoped(Fusion) |
在 Computed 回调中使用 peek() | 使用 use(x),以便触发重新运行 |
在 Computed 回调中让出执行 | Computed 必须立即完成;在其他位置执行异步工作,并将结果传入 Value |
在 Computed 中执行副作用(声音、网络) | 将它们放入 Observer:onChange(...) |
忘记在 UI 拆除时调用 scope:doCleanup() | 始终将作用域生命周期绑定到父级(根作用域、父级的 innerScope) |
使用 Instance.new("Frame") 后再设置属性 | 使用 scope:New "Frame" { ... } — 自动获得默认值和清理功能 |
事后通过 child.Parent = parent 添加子项 | 在属性表中使用 [Children] |
直接在 Fusion 创建的实例上通过 inst.Activated:Connect(...) 连接事件 | 使用 [OnEvent "Activated"] = handler — 会随作用域一起清理 |
在 API 要求 UsedAs<T> 的位置存储常量 | 直接传入常量;Fusion 会处理 |
在 scope:Computed 清理回调中捕获 scope | 使用第二个 scope 参数 — 每次重新计算时都会自动清理 |
| 将同一个作用域传给多个互不相关的 UI 树 | 为每个逻辑分组使用 innerScope/deriveScope |
对由 Fusion 管理的实例使用 :Destroy() | 对作用域调用 doCleanup;让 Fusion 负责拆除 |
| 在同一棵树中混用 Fusion 和 Vide / React Lua | 选择其中一种 |
验证规则
- Fusion 0.x 尚未达到 1.0;不同次要版本之间的 API 会发生变化。 此技能面向 0.3。如果项目固定了其他版本(
wally.toml),引用前请根据该版本的文档核实 API。 elttob.uk/Fusion/0.3是 0.3 的权威参考——较早的 Fusion 文档(0.2 及更早版本)使用了不同的 API(没有作用域;Computed的签名也不同)。请勿混用教程。- 请勿引用超出公共 API 范围的内部类型或字段。 以
_开头的字段并不稳定。 UsedAs<T>是旧文档中称为CanBeState<T>或StateOrValue<T>的现代名称。 概念相同;请核实你安装的 Fusion 实际导出的类型名称。- 关于 Vide / React Lua 的问题——请等待本技能相关章节编写完成后再处理。
示例
examples/fusion-counter.luau—Value+Computed+Observer+New;完整的响应式链。examples/fusion-button.luau— 使用UsedAs属性的Button组件,带作用域注册。examples/fusion-list.luau— 由状态对象驱动的动态子项。examples/fusion-form.luau—[Out "Text"]捕获 + 通过赋值设置 Instance 以实现程序化聚焦;提交处理程序。examples/fusion-animation.luau—Tween+Spring:悬停缩放(Spring)、颜色/透明度过渡(Tween)、投影淡出。examples/fusion-story.luau— Fusion 组件的 UI Labs.story模块;控件 +props.scope清理。