宇宙编辑器 Scheme
策划在编辑器里看到的每一个输入框、下拉、勾选、数组,都由一份 Scheme 描述。 八大模块里,边界重生、爬塔星级、传送表现、刷怪器按钮都动过 Scheme。 这一页讲清楚:Scheme 是什么、怎么加、以及为什么它是「配了没生效」类问题的第一嫌疑人。
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,
}),
},
});
CnName 和 Tip 是策划唯一能读到的说明书。
写「倒计时数值,单位秒」远好过写「countdown」。
单位、取值范围、和别的字段的互斥关系,都要写进 Tip。
每一个新增的 Scheme 字段都必须带 Tip,这不是可选项。
2. 工厂函数速查
全部来自 Src/UniverseEditor/EditorCommon/Type.ts:
| 工厂函数 | 产出 | 典型用法 |
|---|---|---|
| createIntScheme | 整数输入框 | 倒计时、ID、数量 |
| createFloatScheme | 浮点输入框 | 半径、比例 |
| createLongScheme | 64 位整数 | 时间戳、大 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 是编辑期占位文本,方便策划先填中文看效果;
Tid 才是走多语言表的正式 key。导表时 EdTid 不会进 ClientJson,所以运行时代码里写的 「Tid 为空就回退 EdTid」是死代码 —— 永远走不到。 边界重生文本 bug 里有一半就是这个。
正确做法:Tid 空时回退到硬编码兜底串,而不是回退到 EdTid。
3. Scheme 的四个回调
每个 Scheme 实例都有这四个钩子(EditorCommon/Type.ts 里的 class Scheme)。
顺序很重要:
IsAlwaysShow 会直接抛异常(OnBaseCheck 里有断言)'fixed' 会打 warn 日志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 就是这么被误报的)
更好的做法是两边都用真实 Scheme。 用空壳时至少在注释里写清楚为什么(上面那行注释就是补充说明)。
5. 主控树节点(Action)Scheme
主控树上策划摆的每个节点,对应一个 EAction 枚举值 + 一份参数 Scheme + 一份 actionConfig 条目。
全工程 171 个 EAction,其中 82 个 ImplementType: 'Server'。
(该口径来自更早分支;071 分支实测为 105 个 EAction / 83 个 Server,
见旧版编辑器 · 节点类型)
5.1 加一个节点要动三处
EAction.XxxYyy = 'XxxYyy' 和 interface IXxxYyy { ... }Context(能摆在哪:entity / levelPlay / wildEvent / quest)+
ImplementType('Server' 还是 'Client')+
可选 RestrictedMapContext('DungeonOnly' / 'WpOnly')createActionParamsScheme<IXxxYyy>({ Name: EAction.XxxYyy, CnName: '中文名', Fields: {...} })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 后一定要 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:
| 'intWithGenerateButton'const dungeonSpawnerIdScheme = createIntScheme({
CnName: '副本刷怪器ID',
Optional: true,
RenderType: 'intWithGenerateButton',
Tip: '填副本刷怪器表ID,点旁边【生成】读表自动布怪',
});componentRegistry.Register('intWithGenerateButton', IntWithGenerateButton)
(在 Editor/Common/SchemeComponent/Basic/Public.ts)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 lint | lint |
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: true
会让全项目老配置报错。运行时侧配套写好「不配 = 什么默认行为」。IgnoreAllFields 空壳)。
不一致 = 「非法字段」误报 + 数据被静默删。OnFix 升级旧数据,
否则策划打开老关卡一片红。gulp watch(或 check-tsc),产物在 JavaScript_Raw。
与运行时那条流水线各自独立,产物目录也不同。.ts,
运行时 puerts 会报 can not find XxxScheme。OnOpEnter 里 Push 几个,OnOpExit 里就要 Pop 几个。
漏一个会污染后续所有字段的校验上下文。11. 面试可讲的点
好处是加一个配置字段只改一处,UI、校验、默认值、旧数据迁移全都跟着走; 坏处是 Scheme 本身成了单点,编辑侧和校验侧用不同 Scheme 时会出现 「策划填的数据被静默删掉」这种很难查的问题——本项目真实踩到过一次。
① 向后兼容——必须
Optional,并在运行时侧明确「不配等于什么行为」,
否则全项目老配置一夜变红。② 旧数据迁移——如果是改结构而不是纯新增,要写
OnFix 自动升级。③ 全链路打通——Scheme 加了只是「能配」,还要确认导表带上、所有执行分支都透传、 运行时真的去读。本项目就有一个 action 注册了两套 executor,只改一套, 表现就是「配了但某个入口不生效」。
④ 文案——
CnName/Tip 是策划唯一的说明书,写清单位和互斥关系。
更关键的是错误信息要写成「怎么修」。
不存在传送表ID: 10086 策划自己就能解决;
校验失败 就会变成一张回到程序头上的工单。还有一类是「没报错但也没生效」——资源缺失、监听节点没摆、导表没跑。 这类最耗时,所以能加断言的地方都应该加 Error 日志, 把静默失败变成响亮失败——宁可刷屏报错,也别让问题藏着。