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

给扩展的宿主接口:`Scratch.gandi`

扩展跑起来之后有两件事以前只能靠猜:当前跑在哪个编辑器里,和舞台的 DOM 在 哪。Scratch.gandi 就是这两件事的答案。

这一篇是给写扩展的人看的。

谁拿得到#

只有非沙箱扩展有这个命名空间:沙箱扩展跑在 worker 里,那里根本没有 document,给它一个操作 DOM 的接口只会造成误解。Gandi 3.0 从网址装进来的扩展 在页面里执行,所以拿得到。

Scratch.gandi 这个命名空间存在本身就说明这是 Gandi 3.0 系的编辑器。别的 编辑器里它是 undefined,所以每一次访问都要带上 ?.。

一、判断当前编辑器#

if (Scratch.gandi?.isGandi3) {
    // 是 Gandi 3.0
}

要按功能分支的话不要只看它,而要检查那个功能在不在:

if (typeof Scratch.gandi?.stage?.addOverlay === 'function') { /* … */ }

能力探测不会随版本失效,所以这里没有版本号:要判断某个功能有没有,就检查那个 功能本身。

二、在舞台上盖一层自己的 HTML#

想在舞台上放 HTML(<iframe> 那一类)的扩展,过去只能遍历整个页面去判断哪个 <canvas> 是舞台、再猜它的父节点、再把自己插进去。编辑器布局一变这套判断就失效, 而且插错地方会把变量监视器压在底下,那是用户自己放的东西。

现在可以直接申请一层:

const layer = Scratch.gandi.stage.addOverlay({scale: true, interactive: true});
layer.appendChild(myIframe);

// 不用了:
Scratch.gandi.stage.removeOverlay(layer);

拿到的是一个空 <div>,已经贴满舞台、排好层级。画布不动、监视器不动,也不需要 了解编辑器的 DOM 结构。

下面这张图里,橙色的卡片就是一层 overlay:它盖住了舞台上的角色,但盖不住右上角 那个变量监视器。

overlay 盖在画布上,监视器仍在最上面

方法一览#

方法 作用
Scratch.gandi.isGandi3 布尔值。是不是跑在 Gandi 3.0 里。
Scratch.gandi.stage.addOverlay(options?) 一层贴满舞台的 <div>;没有舞台服务时返回 null。
Scratch.gandi.stage.removeOverlay(element) 收掉一层。返回真表示确实收掉了。
Scratch.gandi.stage.getCanvas() 舞台画布。量尺寸、读像素可以,不要改它的样式,也不要把它搬走。
Scratch.gandi.stage.getContainer() 画布和监视器共处的那个盒子。同样只读。

addOverlay 的选项:

选项 默认 含义
scale false 真:这一层的内部坐标系固定成舞台原生尺寸(480×360),缩放交给编辑器。按舞台坐标摆元素即可,不必自己监听尺寸变化。
interactive false 真:这一层接收鼠标事件。注意它会挡住盖住的那块舞台,所以默认是穿透的。
name 无 写到元素的 data-overlay 上,便于排查某一层是谁加的。

作品换了屏幕比例时,scale 说的「原生尺寸」跟着变,不一定是 480×360。

行为细节#

  • 舞台面板此刻没挂载(用户把它关了)也照样返回元素,等舞台回来会自动接上去; 换布局、进出全屏时,这一层跟着画布走,不需要额外处理。

  • 编辑器没提供舞台服务时(无头播放器、单测、别家编辑器),这几个方法返回 null,只告警一次,不抛异常:一个用来探测环境的调用不应该让作品崩溃。

  • 层级是这样叠的(.stage-stack 里从下往上):

    canvas.stage-canvas   最底下
    .stage-overlay        z-index 2   ← addOverlay 给的
    .monitor-layer        z-index 3
    .stage-crossfade      z-index 5
    .stage-drop-hint      z-index 6
    .stage-question       z-index 10

    监视器那条 z-index: 3 就是为这件事写的:扩展加的层永远在画布之上、监视器之下。

三、让积木进编译器:compile 字段#

编译模式下,扩展积木是「调一下扩展的函数」:脚本本身编成了 JS,碰到扩展积木就去调 getInfo() 里那个方法。这一步已经很快(每次调用几十纳秒),但编译器不知道三件事:这块 积木会不会让出、返回什么类型、函数体里是什么。知道了就能把它当原生积木处理。

这三件事写在积木定义的 compile 字段上,三档,每一档都是可选的。不写的积木照旧, 解释器根本不看这个字段。

第一档:声明#

{
    opcode: 'hyp', blockType: Scratch.BlockType.REPORTER, text: '√([A]² + [B]²)',
    arguments: {A: {type: Scratch.ArgumentType.NUMBER}, B: {type: Scratch.ArgumentType.NUMBER}},
    compile: {sync: true, returns: 'number', pure: true}
}
字段 你承诺的事 编译器拿它做什么
sync 函数不返回 Promise,不调 util.yield / yieldTick / startBranch,不停线程。 含这块积木的自制积木可以内联;生成的代码不再检查让出和 Promise。
returns 'number' / 'string' / 'boolean',返回值一定是这个类型。 返回值不再二次转换;「把变量设为 (积木)」的变量可以进数值槽。
pure 结果只由参数决定,没有副作用,不读扩展自己的状态。 参数全是常量时编译期直接算出结果。

compile: 'sync' 是 {sync: true} 的简写。

承诺错了不会崩:声明了 sync 却返回 Promise,编译后当作空串、控制台警告一次并点名 积木;真的让出了,线程状态被改回运行继续走。returns 写错了按声明的类型强转。

第二档:模板#

把积木本体写成一行 JS,编译器把它原地放进生成的代码,函数不再被调用:

{
    opcode: 'hyp', …,
    compile: {returns: 'number', pure: true, js: 'Math.hypot($A, $B)'}
}
{
    opcode: 'setSpeed', blockType: Scratch.BlockType.COMMAND, …,
    compile: {js: '$$.speed[$target.id] = $V'}
}

模板里能用的只有这几样:

占位符 是什么
$A、$B … 该积木的参数,已按 ArgumentType 转成数字 / 字符串 / 布尔;没声明类型的原样给。菜单和字段是字符串常量。
$$ 扩展实例,就是写 getInfo 的那个对象。
$target $stage $runtime $thread 当前角色、舞台、运行时、线程,和积木函数里的 util.target 等是同一个东西。

reporter 和 boolean 的模板是一个表达式;command 的模板是一段语句。模板里没有 util:要 util.stackFrame、要让出、要启动帽子的积木不适合写模板,留给函数就好, 一个扩展里两种可以混用。同一个参数在模板里用了几次,也只算一次。

raw: true 让参数不经转换、按函数收到的原样代进来,给把现有函数体逐字抄成模板 的场合用(比如原函数自己写了 Number(x))。

模板要按参数分支时,js 可以是函数。它收到的是参数的 JS 源码,返回一段源码:

compile: {
    returns: 'string',
    js: ({S, N}, c) => {
        const n = c.constant('N');          // 编译期常量,不是常量时 undefined
        return n === undefined ? `${S}.slice(0, ${N})` : `${S}.slice(0, ${n})`;
    }
}

c 上只有 constant(名)、type(名)、local()(要一个临时变量名)、substack(名)。 返回的源码里还可以用 $ 占位符。函数形式只有页面里加载的扩展能用(Gandi 3.0 的都是)。

第三档:条件和循环#

自定义的「重复」「如果」积木,模板里用 $SUBSTACK(第二个口是 $SUBSTACK2)放 编译好的积木堆,用 $YIELD 放这条脚本该有的让出。编出来的代码和原生「重复执行」一样:

{
    opcode: 'forEachItem', blockType: Scratch.BlockType.LOOP, text: '对列表 [LIST] 的每一项', …,
    compile: {js: 'for (const it of $$.items($LIST)) { $$.current = it; $SUBSTACK $YIELD }'}
}
{
    opcode: 'ifChance', blockType: Scratch.BlockType.CONDITIONAL, branchCount: 2, …,
    compile: {js: 'if (Math.random() < $P) { $SUBSTACK } else { $SUBSTACK2 }'}
}

$YIELD 在普通脚本里是让出一次,在「不刷新屏幕」的脚本里是卡住了才让出;循环模板 不写 $YIELD 就是一个不刷新屏幕的循环。

出错时#

注册扩展时每块积木的 compile 都会检查:占位符必须是参数名或上表里的名字,字符串模板 要能解析。不过的那一块在控制台警告一条,然后当没写,走普通路径;扩展永远装得上。

想一次关掉所有扩展的编译字段,控制台里 JSGenerator.extensionCompile = false。

快多少#

node 里的 warp 循环,每次迭代一个 reporter 加一个 command,纳秒:不写 40,只写声明 29, 写模板 3。扩展库里「更多比较」「字符串处理」「位运算」三个扩展的积木用模板重写后, 单次调用从 240 到 415 纳秒降到 11 到 48 纳秒。

没人维护的扩展#

三档写的都是数据,所以不必只能由扩展作者写。扩展库里停更但常用的扩展,编辑器自己 带一份配置(src/vm/compileProfiles/),装扩展时按 id 合并到积木上;扩展自己写了 compile 的以扩展为准。配置只写和原函数逐字等价的纯函数积木。

四、渐变色积木:colorGradient#

getInfo() 里本来有 color1 / color2 / color3 三个颜色,积木是纯色的。 再给一个 colorGradient,这个扩展的积木就从 color1 渐变到它:

getInfo () {
    return {
        id: 'myext',
        name: '我的扩展',
        color1: '#12B0C9',
        color2: '#0F9DB4',
        color3: '#0D8A9E',
        // 可选:积木从 color1 斜着渐变到这个颜色
        colorGradient: '#1C78B5',
        blocks: [/* … */]
    };
}

不写就还是纯色,和以前一字不差。

几条要知道的:

  • 方向是左上到右下的对角线,按每块积木自己的尺寸缩放,所以长积木和短积木 看起来是一套。方向是全局定死的,不能按扩展改 —— 一个作品里各扩展的积木朝向 不一样会很乱。
  • 只影响积木本体。阴影积木、发光时的高亮、输入框的凹槽还是纯色:那几样本来 就是靠和本体的色差被认出来的。
  • 要轻。 两个颜色差太多,积木栏里会很吵;差一点点(比如同一色相压暗一档、 或者往邻近色偏一点)就够了。
  • 自己写了 color1 的那几块积木不渐变,用自己的纯色。
  • 同一对颜色全局只建一份渐变定义,一千块积木也只有一个 <linearGradient>。

五、C 型积木上能拖出来的参数#

「对列表的每一项」这类循环积木,身体里要用到「这一轮的那一项」。做法和带参数的 hat 一样:参数声明成 Scratch.ArgumentType.CCW_HAT_PARAMETER,积木上就有一颗椭圆,拖出来 放进身体里,读到的是这一轮的值。

C 型积木上的参数椭圆,拖进身体里用

声明#

{
    opcode: 'forRange',
    blockType: Scratch.BlockType.LOOP,
    text: '对 [i] 从 [FROM] 到 [TO]',
    arguments: {
        i: {type: Scratch.ArgumentType.CCW_HAT_PARAMETER},
        FROM: {type: Scratch.ArgumentType.NUMBER, defaultValue: 1},
        TO: {type: Scratch.ArgumentType.NUMBER, defaultValue: 10}
    }
}

LOOP 和 CONDITIONAL 都可以,一块积木上可以有几颗。椭圆上显示的是参数名(这里是 i),这个名字也是取值用的键。

赋值#

开分支时把这一轮的值交给 util.startBranch 的第三个参数,键是参数名:

forRange (args, util) {
    const frame = util.stackFrame;          // 这一次执行这块积木的状态
    if (frame.i === undefined) {
        frame.i = Scratch.Cast.toNumber(args.FROM);
        frame.to = Scratch.Cast.toNumber(args.TO);
    } else {
        frame.i++;
    }
    if (frame.i > frame.to) return;         // 不开分支,循环结束
    util.startBranch(1, true, {i: frame.i});
}

不给第三个参数时沿用上一轮的值。

取值规则#

  • 椭圆找包着它的那块声明了同名参数的 C 型积木,取它这一轮交的值;套了几层同名的, 取最里面一层。
  • C 型积木自己输入格里的椭圆读的是外层的值,不是这块积木上一轮的值。
  • 哪一层都没给(或者给的是 undefined / null),再看 hat 参数;都没有是空字符串。
  • 值跟着线程走:同一段脚本在几个克隆体里同时跑、递归调用自己,各读各的。

编译#

编译模式下身体里的椭圆直接读这块积木这一轮交的值,不经过兼容层;C 型积木本身每一轮 同步调一次扩展函数,函数让出或返回 Promise 时才退回兼容层。套在「瞬时执行」里循环 10 万次、身体里把参数累加到变量,node 里约 9 ms;同样的事用「重复执行」加变量约 1 ms, 差的是每一轮调用扩展函数的开销,读参数本身几乎不花时间。

写了 compile 模板(第三档)的积木不经过 startBranch,用不了这里的参数。

示例#

完整的三块(单参数循环、双参数循环、只跑一次的 CONDITIONAL):

(function (Scratch) {
    'use strict';
    const {ArgumentType, BlockType, Cast} = Scratch;
    class CBlockArgsExample {
        getInfo () {
            return {
                id: 'cblockArgsExample',
                name: 'C 型参数示例',
                blocks: [
                    {opcode: 'forRange', blockType: BlockType.LOOP, text: '对 [i] 从 [FROM] 到 [TO]',
                        arguments: {i: {type: ArgumentType.CCW_HAT_PARAMETER},
                            FROM: {type: ArgumentType.NUMBER, defaultValue: 1},
                            TO: {type: ArgumentType.NUMBER, defaultValue: 10}}},
                    {opcode: 'forEachItem', blockType: BlockType.LOOP, text: '对 [item] [index] 逐项 [LIST]',
                        arguments: {item: {type: ArgumentType.CCW_HAT_PARAMETER},
                            index: {type: ArgumentType.CCW_HAT_PARAMETER},
                            LIST: {type: ArgumentType.STRING, defaultValue: 'a,b,c'}}},
                    {opcode: 'withValue', blockType: BlockType.CONDITIONAL, text: '令 [value] = [V]',
                        arguments: {value: {type: ArgumentType.CCW_HAT_PARAMETER},
                            V: {type: ArgumentType.STRING, defaultValue: 'x'}}}
                ]
            };
        }
        forRange (args, util) {
            const f = util.stackFrame;
            if (f.i === undefined) {
                f.i = Cast.toNumber(args.FROM);
                f.to = Cast.toNumber(args.TO);
            } else {
                f.i++;
            }
            if (f.i > f.to) return;
            util.startBranch(1, true, {i: f.i});
        }
        forEachItem (args, util) {
            const f = util.stackFrame;
            if (f.items === undefined) {
                f.items = Cast.toString(args.LIST).split(',');
                f.n = 0;
            }
            if (f.n >= f.items.length) return;
            f.n++;
            util.startBranch(1, true, {item: f.items[f.n - 1], index: f.n});
        }
        withValue (args, util) {
            util.startBranch(1, false, {value: args.V});   // 不是循环:身体跑一次
        }
    }
    Scratch.extensions.register(new CBlockArgsExample());
})(Scratch);

社区扩展的老做法#

在这个接口之前,社区扩展(高级数据结构、SharkPool 的 JSON 等)是自己注册一种参数积木、 改写 ScratchBlocks.scratchBlocksUtils.isShadowArgumentReporter 让它能被拖出来复制。 Gandi 3.0 照样认这种改写,这些扩展不用改;新写的扩展用上面的声明即可,不必碰积木库。

Scratch 上还有什么#

这一篇只讲 gandi 那个命名空间。Scratch 本身还带着 ArgumentType、BlockType、 TargetType、Cast、Color、translate、Patcher,以及 Scratch.expandedSlots / Scratch.expandedValues(可扩展参数 那对 ➕➖ 的读值函数,沙箱扩展也有)。

积木文字要跟着界面语言换,就用 Scratch.translate:中文写在原地当默认值,英文放进 Scratch.translate.setup({'zh-cn': {}, en: {…}})。那张空的 zh-cn 表不能省,没有它 中文界面会退到英文那张。换语言时编辑器重新调 getInfo(),所以翻译要在 getInfo() 里调。

不要在积木实现里读 window.Scratch。 加载器装完一个扩展之后会把 global.Scratch 上的 vm / runtime / renderer 清成 null,装下一个扩展时还会 整个换掉 global.Scratch。要么像下面这样用闭包捕获,要么在构造函数里存下来:

(function (Scratch) {
    'use strict';
    class MyExt { /* 这里的 Scratch 永远是自己那一份 */ }
    Scratch.extensions.register(new MyExt());
})(window.Scratch);

示例扩展#

public/examples/gandi-showcase.js 是一份能跑的参考:Gandi 3.0 里写扩展能用到 的东西,每样都有一块最小的、真能跑的积木。要照着写的话先看这个。

积木栏里最上面露着最常用的几块,其余按能力分成八组,出厂都折着:

位置 演示什么
最上面 积木栏按钮、带参数的 EVENT hat(「当示例开始(第 n 次)」)、写了模板的「加」、判断 Gandi 3.0、C 形积木上拖出参数的「对 i 从 1 到 10」、舞台上的 HTML 面板
积木的种类 能勾选监视的 REPORTER、COMMAND、BOOLEAN、边沿触发的 HAT、没有下接口的 COMMAND(isTerminal)、单块换色和图标、积木栏里预搭的一段脚本(BlockType.XML)
编译模式 compile 三档:只声明、字符串模板、函数模板、循环模板、两个口的条件模板
C 形积木 LOOP、CONDITIONAL、两个口的 CONDITIONAL(text 写成数组)、点 ➕ 多一整条「否则如果 ⟨ ⟩ 那么 { }」的积木(能加口的 C 形积木);小节「能拖出来的参数」:两颗椭圆的循环、只跑一次的 CONDITIONAL、C 形 + 可扩展参数 + 拖出参数
可扩展参数 ➕➖ 单输入、键值对、段夹在中间、一块上两段、逐槽选类型、参数个数和名字由用户定的 hat
舞台覆盖层 Scratch.gandi.stage 的每个方法:放卡片、放网页、收起、量尺寸、舞台面板开没开
参数类型与菜单 颜色、角度、音符、点阵、造型、声音、内联图片、布尔、allowDropAnywhere;字段菜单、能塞 reporter 的菜单、动态菜单;Scratch.Color
运行时与存档 帧间隔、实际帧率、Scratch.Patcher、返回 Promise 的积木、存进作品的 extensionStorage(作品一份、角色一份)、只给角色的积木(filter)、界面语言
编辑器接口 window.__gandi3Engine:顶栏抽屉里的一块面板、调试面板「引擎」页里的一张卡(见给扩展的引擎接口)

另外还有:整个扩展的 colorGradient;积木文字全部走 Scratch.translate,中文和 英文界面各显示各的;三块 hideFromPalette 的老积木,老作品里照常跑。

它通篇只用 Scratch.*(编辑器接口那一组用宿主公开的 window.__gandi3Engine), 没引任何工具、没碰 Blockly、没遍历页面找 DOM,这正是重点:这些能力是平台给的。

装上之后积木栏里是这样,每一条标题都能点着折起来:

示例扩展在积木栏里

怎么装来试#

顶栏的「扩展库」→ 标签行里的「自定义扩展」→ 卡片上的「安装」,然后填地址。

扩展库里的「自定义扩展」

开发服务器跑在 5181 端口时,示例扩展的地址就是:

http://localhost:5181/examples/gandi-showcase.js

这个框三种都收:.js 网址、素材集市的扩展 ID、集市页面地址。装好之后卡片上的按钮 变成「已安装」,鼠标移上去变「卸载」。

相关#

  • 扩展 —— 装扩展、扩展库那一页怎么用。
  • 可扩展参数 —— expandable 声明和 Scratch.expandedSlots。
  • 积木栏 —— 扩展分类里那些可折叠的分组。