扩展开发:引擎接口
光照、粒子、物理这三个扩展在编辑器里长出来的分节、工具、舞台叠加层、编辑器和面板, 都是通过这一套接口注册的。任何从网址装进来的非沙箱扩展都能用同一套接口,把自己的东西 长到角色栏、装配编辑器、舞台、顶栏抽屉和场景设置里,也可以开一个完全自己画的编辑器。
这一篇是给写扩展的人看的。用户视角的说明见引擎;Scratch.gandi 那一组
更基础的接口见给扩展的宿主接口。
一、接入#
一个扩展文件两边都装:VM 照旧从 globalThis.tempExt 拿积木;编辑器那一半交给
window.__gandi3Engine.install。
// VM 那一半:和往常一样
class MyExtension {
constructor (runtime) { this.runtime = runtime; }
getInfo () { return {id: 'myext', name: '我的扩展', blocks: [/* … */]}; }
}
globalThis.tempExt = {Extension: MyExtension, info: {extensionId: 'myext', name: '我的扩展'}};
// 编辑器那一半:宿主准备好了就装,没准备好先排队;不是 Gandi 3.0 就整个跳过
const mod = {
id: 'myext',
name: '我的扩展',
setup (ctx) {
// 下面各节的 ctx.register… 都写在这里
}
};
if (window.__gandi3Engine) {
window.__gandi3Engine.install(mod);
} else if (typeof window !== 'undefined') {
(window.__gandi3EngineQueue = window.__gandi3EngineQueue || []).push(mod);
}setup 里注册的一切在扩展卸载时自动撤掉,不用自己记。window.__gandi3Engine 不存在
就说明不是 Gandi 3.0,积木照常工作,编辑器那一半不起作用。
二、三档写法#
每个注册点都接受三档写法,按需要自由选,也可以混:
| 档 | 写法 | 拿到什么 |
|---|---|---|
| schema | rows: [{kind: 'number', key: 'radius', …}] |
宿主用造型编辑器那套控件画;主题、撤销、存档全自带 |
| React | component: MyPanel |
用宿主那一份 React(ctx.react)渲染,props 是 {data, set, api} |
| 原生 DOM | render (host, api) { … return () => 清理 } |
一块空地,自己写 HTML;isolated: true 时给 shadow root |
rows 里任何一行都可以是 {kind: 'component', of: MyRow} 或 {kind: 'mount', render}。
三、物体与数据#
引擎扩展的配置挂在物体上:一个物体是造型列表里的一个文件夹(含子文件夹),没有归入
文件夹的造型属于默认物体 ''。角色的每个物体各有一份数据,按命名空间分:
const obj = ctx.objects.current(); // 当前物体(装配编辑器或角色栏正在看的那个)
obj.id; obj.name; obj.spriteId;
obj.costumes; // 属于它的造型名,按顺序
obj.anchors; // 挂点 [{id, name, mode, at?, frames?}]
obj.data('light'); // 这一节的数据(不含默认值)
obj.set('light', {radius: 200}); // 改一部分;进撤销栈,Ctrl+Z 可撤
obj.anchor('a1', costumeName); // 挂点在某一帧的位置 {x, y},本地坐标(y 向下)
obj.items('light'); // 一列的那种节:这个物体上的每一件(不含默认值)
obj.addItem('light', {radius: 200}); // 装一件,名字自动编号(灯 1、灯 2)
obj.setItem('light', id, {radius: 300});
obj.removeItem('light', id);
ctx.objects.list(spriteId); // 角色的全部物体
ctx.objects.mark(spriteId, '文件夹', true); // 把文件夹设为物体
ctx.objects.on('change', (obj, ns) => {}); // 任何物体的数据改了
ctx.objects.costume(); // 当前帧(造型名)数据存在角色的 extensionStorage['gandi3.engine'] 里,随 sb3 保存:
{marks: ['文件夹', …], objects: {[folder]: {anchors: [...], data: {[ns]: {...}}, items: {[ns]: [{id, name, …}]}}}}VM 那一半直接读它,不经过宿主:造型属于哪个物体按最长的文件夹前缀算(文件夹分隔符和资源库一样是 //);克隆体读本体
(sprite.clones[0])的那份;积木要临时改某一只克隆体的值,用 ctx.runtime.override
或自己在扩展里记一张 target → 覆盖 的表。
set / setItem 可以带第三个参数:
obj.setItem('light', id, {radius: 300}, {key: 'radius', label: '改半径'});key 相同且挨着的几次改动并成一条撤销(滑块拖一路是一条,不是一百条);label 是撤销
提示里显示的那句话。装配编辑器拖一件东西时整段拖动本来就已经是一条。
通知的时机:一批改动(一次拖动、一次批量修改)进行当中,objects.on('change') 会合并到
下一帧一次性发;批结束时立刻发。所以在 render 那一档里自己监听数据的扩展,拖动过程中
可能晚一帧收到。
存档带格式版本:extensionStorage['gandi3.engine'] 里有 v: 1。宿主第一次读一份存档时
补齐缺的结构(marks / objects / anchors / items),扩展不用自己兼容老存档。
不经过宿主直接改存档(探针、外部脚本)之后,要调一次 window.__gandi3Engine.debug.notify()
把版本号推上去,否则运行时那一半会以为什么都没变(它每帧靠版本号判断要不要重新读)。
场景级的数据(重力、环境光这类)存在舞台上,一个场景一份:
ctx.objects.scene('physicsWorld'); // {gravityY: -300, …}
ctx.objects.setScene('physicsWorld', {gravityY: -300});VM 那边读 stage.extensionStorage['gandi3.engine'].scene[ns]。
一列的数据有两层#
一列的那种节(灯、发射器、碰撞体)挂在文件夹上,沿造型的上级链一路相加: 这一帧生效的 = 根目录那份 + 各级文件夹那份 + 这一张造型自己多加的几件,顺序从外往里。 任何文件夹放了都生效,不需要先把它设为物体(那个标记只管一份的那种节)。
扩展一般不用管这件事——运行时拿 objectItemsOf(target, ns) 已经按角色现在穿的那张造型
把两层加好了。写的时候要注意改一件就写它所在的那一层:
api.objectFor(ns) // 不带件 = 当前物体那一级(新建落这儿)
api.ownerOf(ns, itemId) // 这一件所在的那一层(改属性、删件用它)
api.costumeObject() // 这一张造型自己那一层(「只加到这一张」)
api.layerOf(ns, itemId) // 'object' | 'costume' | null
api.moveToLayer(ns, itemId, to)// 把一件搬到另一层
objects.resolveItems(target, ns, costumeName) // {items, fromObject, fromCostume}(fromObject 是各级加起来的)
objects.rowOfItem(target, ns, itemId, costumeName) // 这一件存在哪一行:文件夹路径或造型作用域
objects.ownCount(target, ns, costumeName) // 这一张自己加了几件
objects.clearOwnItems(target, ns, costumeName) // 清掉这一张自己加的一份的那种节(材质、刚体)和挂点只有物体这一层。
件 id 在一个角色里唯一(两层一起算)。相加之后「这一件属于哪一层」只能靠 id 问,两层
撞了 id,layerOf / ownerOf 的答案就不唯一,改一件会写到另一件头上。宿主发号时会把整份
存档扫一遍,所以走 addItem / 粘贴 / 复制一份都不会撞;自己往存档里塞件时得自己保证。
四、角色栏里的一节:registerObjectSection#
ctx.registerObjectSection({
id: 'light', // 数据的命名空间
title: '光',
tint: '#ffd34d',
defaults: {on: false, type: 'point', colour: '#ffd27a', radius: 160},
summary: d => (d.on ? `${d.radius}px` : '关'), // 折起来时标题右边那句
rows: [
{kind: 'switch', key: 'on', label: '开'},
{kind: 'anchor', key: 'anchor', label: '挂在', when: d => d.on},
{kind: 'seg', key: 'type', label: '类型', options: ['point', 'spot'], when: d => d.on},
{kind: 'colour', key: 'colour', label: '颜色', when: d => d.on},
{kind: 'number', key: 'radius', label: '半径', suffix: 'px', min: 1, max: 4000, when: d => d.on},
{kind: 'group', label: '进阶', children: [
{kind: 'slider', key: 'intensity', label: '强度', min: 0, max: 4, step: 0.05},
{kind: 'select', key: 'falloff', label: '衰减', options: ['linear', 'quad']}
]},
{kind: 'button', label: '重置', onClick: api => api.set({...api.data(), radius: 160})},
{kind: 'note', text: '半径按舞台像素算'}
]
});seg / select / tags 的 options 可以是字符串,也可以是 {value, label} —— 给用户看的
一律写中文 label,值留英文给积木和存档。group 里的几项等分一行,标签写在控件里。
行的种类:number、slider、seg、select、switch、colour、text、tags、
ref(选一个资源,要给 assetKind)、anchor(选这个物体的一个挂点)、button、group(一行放几个)、
note、mount、component。每一行都可以带 when(d) 只在满足时显示、disabled(d) 灰掉、
hint(d) 在「进行中 / 失败 / 此刻无意义」时给一句提示 —— 控件自己说得清的不要写。
这一节出现在装配编辑器右侧(某个工具的 section 指向它时)。角色栏里不显示。
角色有多个物体时标题右边自动带 ◆ 物体名。
一份还是一列:默认一份(材质、刚体这种物体上只有一套的)。给 many: true 就是一列
挂件——一个物体想装几个装几个,每件有 id 和 name(itemName 是自动起名的词根:灯 1、
灯 2)。一列的节在装配编辑器里一件一件改;defaults 里的字段每件各一份。
运行时用 ctx.runtime.objectItems(target, ns) 读,VM 那一半用 fx-core/host.js 的
objectItemsOf,rig-sync 给 many: true 后 apply / remove 每件各调一次、带 itemId。
五、装配编辑器里的工具:registerRigTool#
ctx.registerRigTool({
id: 'light', label: '光源', key: 'L', tint: '#ffd34d', icon: '◐',
section: 'light', // 用哪一节的数据;右栏就是那一节
draw (g, {data, anchor, selected}) { // data 已合并 defaults
if (!data.on) return;
g.ring(anchor.x, anchor.y, data.radius, {tint: '#ffd34d', dash: true});
if (selected) g.handle('radius', anchor.x + data.radius, anchor.y, {shape: 'big'});
},
onDrag (handle, pt, {anchor, set}) {
if (handle === 'radius') set({radius: Math.round(Math.hypot(pt.x - anchor.x, pt.y - anchor.y))});
},
onPlace (pt, {set, nearestAnchor}) { // 空白处点一下
set({on: true, anchor: nearestAnchor?.id ?? ''});
}
});工具的 section 是一列时,draw / onDrag / paint 每件各调一次,ctx.data 是那一件(合并过
默认值),ctx.item 是它本身,ctx.items 是全部,ctx.set 写回那一件。onPlace 拿到的
ctx.item 是当前选中的那一件(没有就是 null),ctx.create(patch) 新装一件并选中它,
ctx.shiftKey 说按没按 Shift——碰撞体工具就是靠这三样区分「加顶点」「放方框」「开始画多边形」。
g 是宿主的画笔:ring、wedge、line、path、dot、handle、label、trail、
outline、nodes。坐标是物体本地坐标(原点在旋转中心,y 向下)。手柄的命中测试宿主做,
扩展只在 onDrag 里收到手柄名(自己起的名字,宿主会加前缀再去掉)和新位置。
想让用户「拖出来放」(拖多大就是多大的方框),给 onPlaceDrag(from, to, ctx):宿主等到松手才决定 ——
没怎么动(不到 6 像素)仍走 onPlace,动了才走它,拖的过程中宿主画一个框并显示尺寸。
连点好几下才画得出来的东西(多边形)给 sticky: true:放下一个之后工具还在手上,Enter / Esc
才放下;placeHint 是这时画布下面那句提示;onDeleteHandle(handle, ctx) 在用户点中某个手柄
再按 Delete 时被调,删掉那个手柄代表的东西。
同一个工具在舞台编辑里也生效:宿主把它画到舞台上每个角色身上,坐标按角色的位置、
方向、大小换算,onDrag 收到的仍是本地坐标。
点中与拖动:工具在 draw 里画的圈、扇形、轮廓、点都是它的「身体」,用户点在身体上
就选中这把工具,不用先选。movable: true 的工具可以按住身体直接拖,宿主当作拖它的
pos 手柄调 onDrag('pos', pt, ctx);框选和多选整组拖动也走这一条。
位置:这一节里的 dx / dy 是宿主认识的字段——不跟随挂点时它就是本地坐标里的位置,
跟随时是相对那个挂点的偏移;rig-sync 都会把它换算到舞台。onPlace 里直接 create({anchor: '', dx: pt.x, dy: pt.y}),用户点在哪儿东西就在哪儿;属性栏的「跟随」(anchor 行)换挂点时宿主
会替你换算偏移,东西不动。想让东西本身能拖,就在 draw 里给它一个 pos 手柄,onDrag 里写
set({dx: pt.x - anchor.x, dy: pt.y - anchor.y})。
造型本身:ctx.bounds 是当前造型的包围盒(本地坐标,{x0, y0, x1, y1},空造型是 null),
ctx.rasterize() 把当前造型画成一张 ImageData(1 本地单位 = 1 像素,位图的
bitmapResolution 已折算,像素 (i, j) 对应本地坐标 (bounds.x0 + i, bounds.y0 + j)),
ctx.refresh() 在数据没变但想重画这一件时调(异步算完轮廓之后)。放一个「刚好包住造型」的
碰撞体、按造型轮廓生成多边形都靠这三样。
锁住的件:大纲里锁上的件,数据里是 _locked: true。下划线开头的键是编辑器自己用的,
运行时忽略它们;扩展不用管,宿主已经让它在画布上点不中。
画布预览:paint (c2d, view, ctx) 在画布画完底图之后每件各调一次,paintAll (c2d, view, ctxs)
一次拿到全部件;c2d 是画布的 2D 上下文(CSS 像素坐标),view.scale 是本地单位到像素的比例,
view.ox / oy 是物体原点在画布上的位置,view.costume 是只含造型像素的一张图(做遮罩用)。
预览要和舞台一致:光照扩展用的是渲染器光照管线同一条衰减(d = 距离 / 半径,环境光 + Σ 颜色 × 强度 ×
(1 − d²)² / (1 + 集中 × 4d²),乘到造型上)。工具组里的「预览」关掉时不调用。
这一页归谁:registerRigPage#
装配编辑器按扩展分页,一个引擎扩展一页;这把工具画在哪一页,看它是谁注册的。
ctx.registerRigPage({
label: '碰撞', order: 0,
anchors: false, // 这一页不要挂点工具
tint: '#7c5cff',
status (object, costume) { // 页头右边那句:现在配成什么样
const n = object.items('hull').length;
return n ? {text: `${n} 个形状`, tone: 'ok'} : {text: '逐像素', tone: 'warn', hint: '没画碰撞体'};
}
});status 每次重画都调,别在里面做重活。它是给作者看「我配的这些东西现在到底生效成
什么样」的——宿主不知道扩展的规则,所以由扩展自己算。
一页做到后来常常是一个独立的产品面,通用的左右两栏反而是噪音。chrome 说这一页要外壳
的哪几块(不给就是全都要),砍掉的那块用 left / right 换成自己的:
ctx.registerRigPage({
label: '粒子', chrome: {right: false},
right (host, api) {
host.append(buildMyPanel(api));
return () => host.replaceChildren(); // 返回值当卸载函数
}
});chrome 关掉的是那一栏里宿主画的内容,不是那一栏本身:容器(抽屉、折叠按钮、
属性栏外壳)留着,你的 left / right 挂在里面。页自己出了某一栏,宿主那份也不会再画
一遍(不写 chrome 也一样),不会出现两份造型列表或两份属性。
api 的引用固定,字段是活的:页只挂一次(重挂会丢掉滚动位置和正在输入的输入框),
之后每次读到的都是当下的值。要跟着变就订 on:
right (host, api) {
const paint = () => { host.textContent = api.costume + ' / ' + api.selection.kind; };
paint();
return api.on(paint); // 选中、造型、物体、这个角色名下的数据,任一变化就叫一声
}里面有:
object |
当前物体:名字、造型名单、挂点、一份的那种节(材质、刚体)都在它上面 |
objectFor(ns) |
这一节现在归谁。一列的那些(灯、发射器、碰撞体)读写都用它 —— 当前造型可能自己配了一套,拿 object 写会写回整组去 |
costume / frames / costumes / setCostume |
看的是哪一帧,以及换一帧 |
selection / select |
现在选中了什么,以及换选中 |
tool / setTool(id) |
手上拿着哪把工具,以及换一把(pick 是选择)。页自己出工具排时用;拿起来之后在画布上拖就是放一件 |
zoom / setZoom(z) / fitView() |
画布缩放,页自己出缩放条时用 |
bounds / rasterize() |
当前造型的包围盒和像素,和工具上下文里的那两个是同一份(rasterize 是异步的) |
objects / target / vm |
数据层、角色、VM |
status |
这一页的状态,和页头那句是同一份 |
refresh() |
数据没变但要求重画 |
render(host, api) 是整页接管:骨架归你,页面长什么样由你排。画布不用自己写 ——
api.mountCanvas(el) 把宿主那块原样搬进你给的容器:
render (host, api) {
host.innerHTML = '<div class="my-side"></div><div class="my-canvas"></div>';
const off = api.mountCanvas(host.querySelector('.my-canvas'));
return () => { off(); host.replaceChildren(); };
}搬过去的是画布本体、手柄层和标尺 —— 造型渲染、缩放平移、吸附、撤销、快捷键作用域、
命中测试全跟着来,行为一行不变。工具带、缩放条、那张「还没装东西」的引导卡不跟着走,
它们是外壳,归页自己出。容器要有自己的尺寸,position 不能是 static(画布铺满它)。
整页接管时画布上不留任何浮着的宿主东西:工具带、缩放条、右边那条收起把手都不画。缩放
自己出:api.zoom(读)、api.setZoom(z)、api.fitView()。
只换左右两栏就够时不必走到这一步。
用宿主内置的一整页:host: '名字'。扩展包里不带界面代码,页的样子由宿主出,扩展只管
标题、顺序和色点;工具、分节、paintAll 这些照常由扩展注册,宿主的页会用到它们。目前宿主
内置的有 'particles'(装配台的粒子页)。宿主认不出这个名字(老版本)时当没写,退回通用那套:
ctx.registerRigPage({label: '粒子', order: 2, tint: '#ff9a4d', host: 'particles'});类名带上自己的前缀,而且要够独特。页画进的是宿主的 DOM,样式没有护栏:前缀撞上宿主
某个组件(比如取色器的 .cp,它是 position: fixed)整栏会被掀出正常流,量出来的尺寸
还全是对的,极难查。拿不准就 isolated: true —— 自己画的那几块关进 shadow root,宿主的
CSS 进不来、你的也漏不出去,主题变量照样继承。
六、舞台叠加层:registerStageLayer#
ctx.registerStageLayer({
id: 'light', label: '光照', tint: '#ffd34d',
defaultOn: 'selected', // 'on' | 'selected'(只有选中角色时)| 'off'
draw (g, {targets}) {
for (const t of targets) { // {targetId, object, costume, x, y, direction, size, selected}
const d = t.object && {...DEFAULTS, ...t.object.data('light')};
if (!d?.on) continue;
g.ring(t.x, -t.y, d.radius * t.size / 100, {tint: '#ffd34d'});
}
}
});舞台下方多一个开关。坐标是舞台坐标,原点在中心,x 向右,y 向下(舞台 y 取负)。 运行时不画。
叠加层的每个 target 上有 place(lx, ly):把物体本地坐标换算成舞台坐标(已经算进角色的位置、
方向、大小和「旋转方式」——左右翻转的角色只翻 x 不转,不旋转的角色不转)。自己拿
x / y / direction / size 算容易漏掉旋转方式,用它。
七、场景设置:registerSceneSection#
写法和 registerObjectSection 一样,数据存在舞台上,出现在选中舞台时的参数区下面。
运行时读它:VM 那一半直接读舞台的 extensionStorage['gandi3.engine'].scene[ns],
变了就应用(三个引擎扩展是每一步比较一次 JSON)。
八、编辑器:registerEditor#
扩展可以开自己的编辑器窗口。三种作用域:
scope: 'sprite':每个角色一份,标签叫「标题(角色名)」,和「工作区」「造型」一样跟着 当前角色;角色右键菜单的「打开」里有它,顶栏抽屉「扩展」一组里有它。scope: 'global':全局一份,就是一块普通面板。scope: 'asset':每个资源一份,对象 id 原样交给扩展;只能由扩展自己openEditor打开。
用装配编辑器的骨架(scope: 'sprite',默认):
ctx.registerEditor({
id: 'path', scope: 'sprite', title: s => `路径(${s.name})`, icon: '↝', group: '应用',
chrome: {left: false, strip: false}, // 裁掉左栏和帧条;left/canvas/right/strip 四块
tools: ['hull'], // 骨架里只留这些装配工具;不给就全部
draw (g, {object, costume}) { // 编辑器自己的手柄
const pts = object.data('path').points ?? [];
g.outline(pts, {tint: '#f0f'});
g.nodes(pts, {tint: '#f0f', idPrefix: 'p'});
},
onDrag (handle, pt, {object}) {
const i = Number(handle.split(':')[1]);
const points = (object.data('path').points ?? []).map((p, k) => (k === i ? pt : p));
object.set('path', {points});
},
onPlace (pt, {object}) {
object.set('path', {points: [...(object.data('path').points ?? []), pt]});
},
render (host, {object, costume, frames, setFrame}) { // 右栏,三档写法任选
host.textContent = `${object.name} · ${frames.length} 帧`;
}
});chrome.canvas: 'preview' 让底图不压暗。draw / onDrag / onPlace / render 的上下文里有
object、costume、frames、target、vm、setFrame、select。
完全自己画(chrome: false):
ctx.registerEditor({
id: 'sheet', scope: 'sprite', title: '表格', chrome: false,
render (host, {subjectId, target, vm}) {
host.innerHTML = '<table>…</table>';
return () => {/* 关掉时清理 */};
}
// 或 component: MySheet —— props 是 {subjectId, target, vm}
});打开:ctx.openEditor('sheet') 跟着当前角色;ctx.openEditor('sheet', spriteId) 钉在那个
角色。placement: {group: 'center', after: 'blocks'} 说落在哪;openOnInstall: true
装上就打开(会动别人布局的事默认不做)。
九、面板:registerPanelWith#
不绑定对象的窗口:
ctx.registerPanelWith('curve', '曲线编辑器', host => {
host.textContent = '…';
return () => {/* 关掉时清理 */};
}, {
icon: '<svg …>', // SVG、内置图标名,或一两个字
group: '应用', // 抽屉里分在哪一组
hint: '编辑缓动曲线',
placement: {group: 'float'},
bar: true // false 则不进抽屉,只能由扩展自己打开
});面板出现在顶栏抽屉的扩展一组,点击打开或关闭;扩展卸载后格子消失。
十、注入已有界面:contribute#
ctx.contribute('sprite.inspector.fields', {
where: 'after', target: 'size',
rows: [{kind: 'number', key: 'mass', label: '质量', suffix: 'kg'}]
});where:prepend、before、after、append、replace、wrap;后四种要用 target
点名槽位里的部件。replace 是独占的,先到先得。内容三档任选;when(subject) 决定这一次
画不画。按住 Alt + Shift 时被注入的部件描出轮廓、标上槽位名,方便看是谁改的(Alt 单独按是装配编辑器的编辑键,所以要加 Shift)。
现有的插槽:
| 插槽 | 部件 |
|---|---|
sprite.inspector.name |
name、visible |
sprite.inspector.fields |
x、y、size、direction |
stage.overlaybar |
舞台下方那排开关 |
editor.rig.left、editor.rig.right、editor.rig.strip |
装配编辑器的三块 |
ctx.slots.list() 列出全部名字;名单里其余的(sprite.row.badges、stage.toolbar、costume.*、toolbox.header、menu.*)已预留,宿主暂未接上。
十一、其它注册点#
registerBlockCategory({id, label, tint, group: 'engine'}):积木栏分类的颜色和分组。registerDebugRow({id, label, tint, sample: () => ({ms, note})}):调试面板「引擎」页里的一张卡,ms是这一帧花的时间。registerCostumeLayer、registerAssetKind:已登记,宿主暂未在造型编辑器和资源库里画出来。
十二、运行时#
ctx.runtime.objectData(target, ns) // 这一只角色 / 克隆体此刻的一节数据(含临时覆盖,含默认值前请自己合并)
ctx.runtime.override(target, ns, patch) // 积木改的临时覆盖;null 清掉
ctx.runtime.anchor(target, anchorId) // 挂点在舞台上的位置(已按位置、方向、大小换算)
ctx.runtime.objectId(target) // 这一只此刻属于哪个物体
ctx.runtime.sceneData(ns) // 场景级数据
ctx.runtime.frameBudgetMs() // 一帧的预算,超了就该降质量这些是给编辑器那一半用的。VM 那一半在别的编辑器里也要能跑,所以三个引擎扩展把「每一步
扫一遍角色,该有就建、不该有就拆」写在自己的 fx-core/rig-sync.js 里,直接读
extensionStorage,不依赖宿主。
十三、宿主给的东西#
ctx.vm // scratch-vm
ctx.react / ctx.reactDom // 宿主那一份 React,别自己再打包一份
ctx.ui // {Field, Group, NumberField, ColourField, Segmented, Select, IconButton}
ctx.settings.get / set // 插件设置(插件系统的)
ctx.registerEntry / registerSettings / registerPanel / openPanel // 插件系统原有的十四、和光照一起画#
装了光照扩展时,renderer.lighting 是光照管线(scratch-render 的 lighting/LightingPipeline.js),别的扩展
可以接进去:
const lighting = renderer.lighting;
// 自己的一组灯:和场景的灯叠在一起,按 key 分开,互不覆盖;null 或 [] 撤掉
lighting.setExtraLights('particles', [
{type: 'point', x, y, color: [1, 0.6, 0.2], intensity: 0.8, radius: 60, height: 20, halo: 0.5, shadows: false}
]);- 字段和光照扩展交给渲染器的灯一样(舞台坐标):
type(point / spot)、color(0–1)、intensity、radius、falloff、height、halo、shadows、sourceRadius,聚光还有direction/angle/softness。所有扩展加起来最多 256 盏。数量多的灯设shadows: false:投影的灯每盏多画一遍,而且和场景的灯 共用 64 盏的上限。 - 自己的着色器要受光:主绘制那一趟里(draw-bridge 回调收到的
options.lighting为真),lighting.receiverUniforms()给出光照缓冲等 uniform,lighting.constructor.RECEIVER_GLSL是一段 GLSL (ES 3.00;1.00 先#define LIT_TEXTURE texture2D),里面的litReceive(colour, receive)和没有立体感的 角色受光一样。 - 这一趟画进的是浮点目标:大于 1 的颜色会泛光,之后整帧统一曝光、色调映射和调色。所以光照开着时,发光的东西 直接写亮的颜色,不用自己再画光晕。