首页 / 编辑器专栏 / 宇宙编辑器 Scheme(结构描述)

宇宙编辑器 Scheme

策划在编辑器里看到的每一个输入框、下拉、勾选、数组,都由一份 Scheme 描述。 八大模块里,边界重生、爬塔星级、传送表现、刷怪器按钮都动过 Scheme。 这一页讲清楚:Scheme 是什么、怎么加、以及为什么它是「配了没生效」类问题的第一嫌疑人。

Src/UniverseEditor/** gulp 构建,非 npm compile 65 个组件 Scheme 171 个 EAction

1. Scheme 是什么

一句话:Scheme = 配置项的元数据(类型 + 默认值 + UI 呈现 + 校验规则 + 自动修复)。 它不是「数据」,而是「描述数据长什么样」的对象。

// Src/UniverseEditor/EditorCommon/Scheme/Component/BoundaryRespawnComponentScheme.ts
export const boundaryRespawnConditionScheme = createObjectScheme<IBoundaryRespawnCondition>({
    Name: 'BoundaryRespawnConditionScheme',
    CnName: '倒计时判断条件',   // ← 策划在编辑器里看到的名字
    NewLine: true,
    ShowName: true,
    Optional: true,               // ← 可选字段,不勾就不下发
    Fields: {
        CountdownTime: createIntScheme({
            CnName: '倒计时',
            Tip: '倒计时数值,单位秒',   // ← 鼠标悬停提示
            CreateDefault: () => 15,     // ← 新建时的默认值
        }),
        BoundaryRangeEntityIds: createArrayScheme({
            ...refEntityListScheme,
            CnName: '边界范围实体数组',
        }),
        TriggerOnLeave: createBooleanScheme({
            CnName: '取反触发(边界外才倒计时)',
            Tip: '勾选后,边界范围内为安全区,玩家离开边界才开始倒计时',
            CreateDefault: () => false,
        }),
    },
});
Scheme 里写的是给策划看的话 CnName 和 Tip 是策划唯一能读到的说明书。 写「倒计时数值,单位秒」远好过写「countdown」。 单位、取值范围、和别的字段的互斥关系,都要写进 Tip。 每一个新增的 Scheme 字段都必须带 Tip,这不是可选项。

2. 工厂函数速查

全部来自 Src/UniverseEditor/EditorCommon/Type.ts:

工厂函数产出典型用法
createIntScheme整数输入框倒计时、ID、数量
createFloatScheme浮点输入框半径、比例
createLongScheme64 位整数时间戳、大 ID
createStringScheme文本输入框Mark、GroupId
createBooleanScheme勾选框开关类字段
createEnumScheme下拉传送表现类型、状态枚举
createConstEnumScheme常量对象下拉从 Record 生成选项
createArrayScheme可增删列表星级列表、实体数组
createObjectScheme一组字段(折叠块)把相关字段打包
createDynamicScheme「先选类型再填对应字段」关卡 Id 来源(Excel / 变量)
createVectorScheme / createRotatorScheme三分量输入位置、朝向
createAssetScheme资产选择器Sequence、材质 DA
createEntityIdScheme实体拾取器指定关卡里的某个实体
createEdTidTextScheme / createTidTextScheme多语言文本对永远成对出现,见下
createComponentScheme实体组件 Scheme自动补 Disabled / EdIsLocked 字段
createActionParamsScheme主控树节点参数传送关卡、退出副本…

2.1 EdTid / Tid 永远成对

EdTidRespawnText: createEdTidTextScheme({
    Name: 'EdTidRespawnText',
    CnName: '重生提示文本(EdTid)',
    CreateDefault: () => '不要离开任务区域',
    IsDefaultCreate: true,
    IsAlwaysShow: true,
}),
TidRespawnText: createTidTextScheme({
    Name: 'TidRespawnText',
    CnName: '重生提示文本(Tid)',
    TextKeyPrefix: 'BoundaryRespawnRespawnText',   // ← 生成的多语言 key 前缀
}),
EdTid 运行时不导出 EdTid 是编辑期占位文本,方便策划先填中文看效果; Tid 才是走多语言表的正式 key。
导表时 EdTid 不会进 ClientJson,所以运行时代码里写的 「Tid 为空就回退 EdTid」是死代码 —— 永远走不到。 边界重生文本 bug 里有一半就是这个。
正确做法:Tid 空时回退到硬编码兜底串,而不是回退到 EdTid。

3. Scheme 的四个回调

每个 Scheme 实例都有这四个钩子(EditorCommon/Type.ts 里的 class Scheme)。 顺序很重要:

1
CreateDefault()
策划点「+」新建这个字段时用什么初值。 不给默认值 + IsAlwaysShow 会直接抛异常(OnBaseCheck 里有断言)
2
OnModify(from, to, type, fieldKey)
字段被改动时触发,用于联动其它字段。 例:改了「刷怪类型」就把不适用的子字段清空
3
OnFix(value, parent, fieldKey) → 'fixed' | 'nothing'
自动修复:把旧数据升级到新结构。 加了新必填字段、改了枚举值、拆了一个字段成两个 —— 都在这里补, 否则老关卡一打开就一片红。返回 'fixed' 会打 warn 日志
4
OnSchemeCheck(value, messages, scheme) → 错误数
校验:往 messages 里 push 人话,返回错误条数。 校验发生在 Fix 之后,所以能修的先修掉再报

3.1 一个完整的校验示例

// CustomBaseScheme/TeleportIdScheme.ts —— 传送点 ID 引用校验
export const teleportIdScheme = createAkiExcelKeySelectorScheme({
    ExcelType: EAkiExcelType.Teleporter,
    CnName: '传送点Id',
    Tip: '引用c.传送.xlsx',
    OnSchemeCheck: (teleporterId, messages): number => {
        // 模板检查阶段跳过:模板里的引用允许悬空
        if (globalContext.Get('IsTemplateChecking')) { return 0; }
        if (!getTableParser('Teleporter').GetTeleporterById(teleporterId)) {
            messages.push(`不存在传送表ID: ${teleporterId}`);   // ← 策划直接能看懂
            return 1;
        }
        return 0;
    },
});
校验信息写「怎么修」而不是「哪错了」 不存在传送表ID: 10086 ✅ 策划知道去 c.传送.xlsx 查
[清除触发者身上搬运的携带物] 只能在 [交互/触发组件]上指定!!! ✅ 直接说了正确位置
校验失败 ❌ 等于没说

3.2 globalContext:校验时「当前在哪」

校验函数经常需要知道上下文——当前在哪个关卡、哪个实体、哪个组件里。 Scheme 通过 OnOpEnter / OnOpExit 压栈出栈:

// levelPlayScheme
OnOpEnter: (value): void => {
    globalContext.Push('LevelId', value.LevelId);
    globalContext.Push('LevelPlay', value);
    globalContext.Push('RootContext', ...);
},
OnOpExit: (value): void => {
    globalContext.Pop('LevelId');
    globalContext.Pop('LevelPlay');
    globalContext.Pop('RootContext');
},

于是深层字段的校验里能直接 globalContext.Get('LevelId')。 Push / Pop 必须严格配对,漏 Pop 会污染后续所有校验。

4. 双 Scheme:最坑的一个坑

同一份数据,编辑时用的 Scheme 和校验时用的 Scheme 可能不是同一个。

// Scheme/Var/LevelPlay.ts:385 —— 通用玩法 scheme(校验走这条)
export const levelPlayScheme = createObjectScheme<ILevelPlay>({
    Fields: {
        // 爬塔星级:校验侧用空壳(IgnoreAllFields)取消检查
        TowerStarConfig: createObjectScheme<ITowerStarConfig>({
            Name: 'TowerStarConfig',
            Optional: true,
            IgnoreAllFields: true,      // ← 空壳!不校验内部字段
            Fields: {} as never,
        }),
    },
});

// Scheme/Var/LevelPlay.ts:499 —— 副本主控树 scheme(编辑走这条)
export const levelPlayDungeonScheme = createObjectScheme<ILevelPlay>({
    Fields: {
        ...levelPlayScheme.Fields,
        TowerStarConfig: towerStarConfigScheme,   // ← 覆盖成真实 scheme
    },
});
「非法字段」误报的第一顺位嫌疑人 ObjectScheme.OnBaseFix 里有这段(Type.ts:1530):
for (const key in value) {
    if (!fields[key] && !key.startsWith('_') && !scheme.IgnoreAllFields) {
        keysToRemove.push(key);   // ← 直接 delete 掉!
    }
}
如果校验侧的 Scheme 没有 IgnoreAllFields、又没有声明那个字段, 策划辛苦填的数据会被静默删除并报「非法字段」。
看到「非法字段」先别当脏数据处理,先对一遍编辑 Scheme 和校验 Scheme。 (TowerStarConfig 就是这么被误报的)
IgnoreAllFields 的代价 用空壳绕过校验,代价是那块数据完全没有类型保护: 字段拼错、类型写错、结构过期,编辑器全都不会提醒。
更好的做法是两边都用真实 Scheme。 用空壳时至少在注释里写清楚为什么(上面那行注释就是补充说明)。

5. 主控树节点(Action)Scheme

主控树上策划摆的每个节点,对应一个 EAction 枚举值 + 一份参数 Scheme + 一份 actionConfig 条目。 全工程 171 个 EAction,其中 82 个 ImplementType: 'Server'。 (该口径来自更早分支;071 分支实测为 105 个 EAction / 83 个 Server, 见旧版编辑器 · 节点类型)

5.1 加一个节点要动三处

1
Interface/IAction.ts —— 加枚举 + 参数接口
EAction.XxxYyy = 'XxxYyy' 和 interface IXxxYyy { ... }
2
Common/Operation/Action.ts —— 加 actionConfig 条目
Context(能摆在哪:entity / levelPlay / wildEvent / quest)+ ImplementType('Server' 还是 'Client')+ 可选 RestrictedMapContext('DungeonOnly' / 'WpOnly')
3
EditorCommon/Scheme/Action/*.ts —— 加参数 Scheme
createActionParamsScheme<IXxxYyy>({ Name: EAction.XxxYyy, CnName: '中文名', Fields: {...} })
Context 决定节点在哪能被摆出来 Context: ['entity'] 的节点只在实体的交互/触发组件里出现, 主控树上根本看不到。
「策划说找不到这个节点」→ 先查 actionConfig 的 Context。

5.2 给已有节点加可选参数(实例)

「跨地图传送表现」这个需求,要给三个已有节点加同一个可选参数。 做法是抽一个共享 Scheme,三处引用:

// Scheme/Action/LevelPlay.ts:280
/** 单个传送表现下拉:飞天遁地(默认)/淡入淡出,未来新表现在此扩展 */
const createTeleportShowEnumScheme = (cnName: string, newLine: boolean) =>
    createEnumScheme<ETeleportShowType>({
        CnName: cnName,
        Config: {
            [ETeleportShowType.FlyThrough]: '飞天遁地',
            [ETeleportShowType.FadeInOut]: '淡入淡出',
        },
        CreateDefault: () => ETeleportShowType.FlyThrough,
    });

/** 传送表现:进入/离开各一个下拉,作为可选字段挂到传送类节点 */
export const teleportShowScheme = createObjectScheme<ITeleportShowInfo>({
    Name: 'ShowInfo',
    CnName: '传送表现',
    Optional: true,     // ← 老数据不填也合法
    NoFolder: true,     // ← 不折叠,两个下拉平铺展示
    Fields: {
        EnterType: createTeleportShowEnumScheme('进入', false),
        LeaveType: createTeleportShowEnumScheme('离开', false),
    },
});

// 三个节点各引用一行:
// teleportLevelScheme (传送关卡) / 传送副本 / SetDungeonExitPosition (退出副本)
ShowInfo: teleportShowScheme,
关键点为什么
Optional: true已有节点加字段必须可选,否则全项目老配置一夜变红
运行时侧「不配 = 默认值」接口注释里明确写 不配则飞天遁地,读取端用 ?? FlyThrough
NoFolder: true只有两个下拉,折叠反而多一次点击
抽 createTeleportShowEnumScheme「进入」「离开」结构一样,将来加第三种表现只改一处
Scheme 加了 ≠ 运行时收到了 「跨地图传送表现」上线后仍出现「退出副本还是飞天遁地」, 根因是同一个 action 注册了 player / entity 两套 executor,只有 player 版透传了 ShowInfo。 详见 Bug#7。
改 Scheme 后一定要 grep 这个 action 名,确认所有 executor 都改到。

6. 组件 Scheme

实体组件的 Scheme 用 createComponentScheme,它会自动补两个隐藏字段:

// Scheme/Component/ComponentRegistry.ts
export function createComponentScheme<T>(type: Partial<ComponentScheme<T>>): ComponentScheme<T> {
    if (type?.Fields) {
        fields.Disabled   = createBooleanScheme({ HideFlag: ETabFlag.AlwaysHide, Optional: true });
        fields.EdIsLocked = createBooleanScheme({ HideFlag: ETabFlag.AlwaysHide, Optional: true });
    }
    ...
}
字段 / 选项作用
Disabled组件禁用。IsCheckWhileDisabled 决定禁用时还校不校验
EdIsLocked编辑器锁定(防误改)。IsDefaultEdLock 控制默认锁
IsDefaultDisabled模板里新建时默认禁用
OnOpEnter/Leave自动把当前组件 Push 进 globalContext.Component,深层校验能拿到

目录里现有 65 个组件 Scheme(EditorCommon/Scheme/Component/), 本站涉及的 BoundaryRespawnComponentScheme.ts 是其中之一。

7. 自定义渲染(加按钮)

默认渲染只有输入框、下拉、勾选。想在字段旁边加一个按钮(比如刷怪器的「生成」), 要走自定义 RenderType:

1
在 Type.ts 的 TElementRenderType 联合类型里加名字
| 'intWithGenerateButton'
2
Scheme 里指定 RenderType
const dungeonSpawnerIdScheme = createIntScheme({
    CnName: '副本刷怪器ID',
    Optional: true,
    RenderType: 'intWithGenerateButton',
    Tip: '填副本刷怪器表ID,点旁边【生成】读表自动布怪',
});
3
写 React 组件并注册
componentRegistry.Register('intWithGenerateButton', IntWithGenerateButton) (在 Editor/Common/SchemeComponent/Basic/Public.ts)
编辑器 UI 是 react-umg import { HorizontalBox, EditableTextBox } from 'react-umg' —— React 语法渲染 UMG 控件。 改动数据用 immer 的 produce,不要直接改 props。

8. 构建:gulp,不是 npm run compile

最容易踩的流水线区别 Src/UniverseEditor/** 走 gulp,输出到 JavaScript_Raw;
Src/Game/** 和 Src/Core/** 走 npm run compile,输出到 JavaScript。
两者的产物目录不同,因此只跑其中一条时,另一侧看不到任何变化。
命令作用
gulp watch监听编译(日常开发用这个)
gulp watch --hot-reload热重载,不重启编辑器
gulp check-tsc只做类型检查
gulp check-circular循环依赖检查
gulp lintlint

9. 分层纪律:Game 不许 import UniverseEditor

// ❌ Src/Game/** 里禁止
import { getTableParser } from '../../UniverseEditor/EditorCommon/TableParsers/...';

// ✅ 只能 import 纯类型的 Interface
import type { ITowerStarConfig } from '../../UniverseEditor/Interface/ILevelPlay';
目录Game 层能不能 import理由
UniverseEditor/Interface/✅ 可以纯接口/枚举,无运行时依赖
UniverseEditor/Common/⚠️ 看情况有些是纯函数,但容易牵出依赖
UniverseEditor/EditorCommon/❌ 禁止依赖编辑器上下文、表解析器,运行时根本不存在
UniverseEditor/Editor/❌ 禁止React/UMG 编辑器 UI

需要编辑器侧的数据怎么办?让业务方查表后当参数传进来。 配置从 ClientJson 走 ConfigManager,不是从编辑器代码里拿。

10. 改 Scheme 的自查清单

① 新字段是不是 Optional
给已存在的结构加字段,不写 Optional: true 会让全项目老配置报错。运行时侧配套写好「不配 = 什么默认行为」。
② CnName / Tip 是给策划看的
写清单位、取值范围、和别的字段的互斥关系。 策划看不懂就会来问你,问一次的时间够写十个 Tip。
③ 编辑 Scheme 和校验 Scheme 是同一个吗
grep 这个字段名,看有没有第二处定义(IgnoreAllFields 空壳)。 不一致 = 「非法字段」误报 + 数据被静默删。
④ 老数据需要 OnFix 吗
改了枚举值、拆了字段、加了必填 —— 都要写 OnFix 升级旧数据, 否则策划打开老关卡一片红。
⑤ 运行时侧真的读到了吗
Scheme 只管「能配」,还要确认:导表流水线带上了、 所有 executor 都透传了、运行时代码真的去读了这个字段。
⑥ 编辑器侧构建入口是 gulp
入口是 gulp watch(或 check-tsc),产物在 JavaScript_Raw。 与运行时那条流水线各自独立,产物目录也不同。
⑦ 每个 export 单独一个文件
PascalCase 的 scheme export 共用一个 .ts, 运行时 puerts 会报 can not find XxxScheme。
⑧ Push / Pop 配对了吗
OnOpEnter 里 Push 几个,OnOpExit 里就要 Pop 几个。 漏一个会污染后续所有字段的校验上下文。

11. 面试可讲的点

这套关卡编辑器是怎么做的?中级
自研的「宇宙编辑器」,跑在 UE 编辑器内,UI 层是 react-umg(React 语法渲染 UMG)。 核心是一套 Scheme 元数据系统:每个配置项由一个 Scheme 对象描述它的类型、默认值、 UI 呈现、校验规则和自动修复逻辑,编辑器根据 Scheme 反射式地生成整个界面。

好处是加一个配置字段只改一处,UI、校验、默认值、旧数据迁移全都跟着走; 坏处是 Scheme 本身成了单点,编辑侧和校验侧用不同 Scheme 时会出现 「策划填的数据被静默删掉」这种很难查的问题——本项目真实踩到过一次。
给已有配置结构加一个字段,要注意什么?高级
四件事:
① 向后兼容——必须 Optional,并在运行时侧明确「不配等于什么行为」, 否则全项目老配置一夜变红。
② 旧数据迁移——如果是改结构而不是纯新增,要写 OnFix 自动升级。
③ 全链路打通——Scheme 加了只是「能配」,还要确认导表带上、所有执行分支都透传、 运行时真的去读。本项目就有一个 action 注册了两套 executor,只改一套, 表现就是「配了但某个入口不生效」。
④ 文案——CnName/Tip 是策划唯一的说明书,写清单位和互斥关系。
配置校验应该做到什么程度?高级
原则是「能在编辑期拦下的,绝不留到运行期」。 引用型字段(表 ID、实体 ID、资产路径)必须查存在性; 互斥字段必须校验组合合法性(比如「传送队伍」不能和「玩家当前位置」同时配); 上下文限制必须校验(某个节点只能摆在交互组件上)。

更关键的是错误信息要写成「怎么修」。 不存在传送表ID: 10086 策划自己就能解决; 校验失败 就会变成一张回到程序头上的工单。

还有一类是「没报错但也没生效」——资源缺失、监听节点没摆、导表没跑。 这类最耗时,所以能加断言的地方都应该加 Error 日志, 把静默失败变成响亮失败——宁可刷屏报错,也别让问题藏着。