mcpField
R
roblox-ui

roblox-ui

github.com/afrxo设计与 UI1376 次安装27 周前更新
代码仓库

关于 roblox-ui

Roblox UI 是一项介绍 Roblox UI 引擎并指导使用 Fusion 0.3 构建界面的技能。它提供引擎参考和设计建议,同时要求遵循项目已有的 UI 框架与模式。

主要功能

  1. 01Roblox UI 引擎参考
  2. 02Fusion 0.3 实例模式
  3. 03Fusion 状态与动画指导
  4. 04用户体验、布局和无障碍指南

适用场景

  1. 01构建新的 Roblox UI 组件
  2. 02选择布局与输入模式
  3. 03使用 Fusion 实例和状态
  4. 04应用响应式与无障碍界面建议

安装

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

$npx skills add https://github.com/afrxo/roblox-agent-skills --skill roblox-ui

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

包含的文件

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

examples/
.gitkeep
fusion-animation.luau
fusion-button.luau
fusion-counter.luau
fusion-form.luau
fusion-list.luau
fusion-story.luau
references/
design-guidelines.md
fusion-instances.md
fusion-state.md
roblox-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(源代码)。

深入了解:


集成策略

本技能介绍的是推荐模式;它不授权重写现有 UI。将规则应用于新组件,以及用户明确要求修改的内容。**遵循项目选定的 UI 框架。**如果代码库使用 Roact,不要在未收到要求时迁移到 Fusion。如果现有组件混用了偏移量和比例值,就沿用这种混合方式。如果已经有主题模块,就使用它——不要再创建一个并行模块。设计指南是建议,而非强制要求。

完整规则:../../shared/integration-policy.md。


Roblox UI 引擎 — 心智模型

每个 Roblox UI 都是由 GuiObject 构成的树,并以 UI 容器为父级。容器决定 UI 的渲染位置;GuiObject 决定显示的内容。

容器(UI 的渲染位置)
容器显示位置所属位置
ScreenGui玩家屏幕上的平面 2D 覆盖层Players[X].PlayerGui(每个角色都会从 StarterGui 复制过来)
SurfaceGui3D 世界中 BasePart 的某个面上(类似广告牌)BasePart,或设置了 Adornee 的 StarterGui
BillboardGui悬浮在 3D 空间中,始终朝向摄像机,可选设置 MaxDistanceBasePart / 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 LuaReact 风格组件;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

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 的问题——请等待本技能相关章节编写完成后再处理。

示例

评论

支持 Markdown