Gandi 3.0文档
官网 打开编辑器
文档/扩展

扩展开发:引擎接口

光照、粒子、物理这三个扩展在编辑器里长出来的分节、工具、舞台叠加层、编辑器和面板, 都是通过这一套接口注册的。任何从网址装进来的非沙箱扩展都能用同一套接口,把自己的东西 长到角色栏、装配编辑器、舞台、顶栏抽屉和场景设置里,也可以开一个完全自己画的编辑器。

这一篇是给写扩展的人看的。用户视角的说明见引擎;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 的颜色会泛光,之后整帧统一曝光、色调映射和调色。所以光照开着时,发光的东西 直接写亮的颜色,不用自己再画光晕。

相关#