Scratch 插件编写完全指南

编写 Scratch 插件中一些不为人知的技巧

首先是一份官方的样例

做一个简单的注释版本,更详细的见官方文档

LLK/scratch-vm


const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const TargetType = require('../../extension-support/target-type');

// 用于国际化
const formatMessage = require('format-message');

class SomeBlocks {
constructor (runtime) {
// 可获取到 vm 的 runtime, 取得对舞台的控制
this.runtime = runtime;
}
// 插件描述信息
getInfo () {
return {

// 插件唯一 id
id: 'someBlocks',

// 插件主副颜色 (block颜色以及可嵌入block部分的颜色)
color1: '#FF8C1A',
color2: '#DB6E00'

// 插件在gui加载后,菜单中显示的名称 GUI插件列表页名称在GUI中设置)
name: formatMessage({
id: 'extensionName',
defaultMessage: 'Some Blocks',
description: 'The name of the "Some Blocks" extension'
}),

// 插件 block 显示的icon
blockIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',

// 插件在GUI菜单中显示的icon
menuIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',

// 这个文档属性好像还未启用
docsURI: 'https://....',

// block 的定义
blocks: [
{
// 相当于 block 的 id,确定后尽量不要更改,否则导致旧项目无法加载对应block
opcode: 'myReporter', // becomes 'someBlocks.myReporter'

// 4种常用的积木类型 布尔/命令/帽子(事件)/返回值 BOOLEAN COMMAND HAT REPORTER

blockType: BlockType.REPORTER,

// 用于 BlockType.CONDITIONAL 不常用略过
branchCount: 0,

// 中断运行 不常用略过
terminal: true,

// 阻塞 不常用略过
blockAllThreads: false,

// block 显示内容 包含 LETTER_NUM / TEXT 参数
text: formatMessage({
id: 'myReporter',
defaultMessage: 'letter [LETTER_NUM] of [TEXT]',
description: 'Label on the "myReporter" block'
}),
// 参数 类型/默认值 定义
arguments: {
LETTER_NUM: {
// 参数类型
// https://github.com/LLK/scratch-vm/blob/develop/src/extension-support/argument-type.js
type: ArgumentType.NUMBER,
// 默认值
default: 1
},

TEXT: {
type: ArgumentType.STRING,
default: formatMessage({
id: 'myReporter.TEXT_default',
defaultMessage: 'text',
description: 'Default for "TEXT" argument of "someBlocks.myReporter"'
})
}
},
// 同 opcode
func: 'myReporter',

// 可选 仅在 人物/舞台 显示
filter: [TargetType.SPRITE]
},
{
// Another block...
}
],
// 插件的下拉菜单定义
menus: {

// 静态菜单
menuA: [

{
// selector item 的 value
value: 'itemId1',

// selector item 显示的文本
text: formatMessage({
id: 'menuA_item1',
defaultMessage: 'Item One',
description: 'Label for item 1 of menu A in "Some Blocks" extension'
})
},

// value / text 一致
'itemId2'
],

// 动态 菜单, 用户点击菜单会实时调用 getItemsForMenuB 生成, 返回值结构同上
menuB: 'getItemsForMenuB',

menuC: {
// 下拉菜单是否接受 reporter 作为输入
acceptReporters: true,
// 接受上面两种形式菜单
items: [/*...*/] || 'getItemsForMenuC'
}
},
};
};
// 动态菜单
getItemsForMenuC (editingTargetId) {
// 动态菜单会接收到当前人物 TargetId
return ['optional1', 'optional2']
}

// 点击 myReporter block 后调用的函数
// reporter 为带有返回值的函数
myReporter (args) {
console.log(args.TEXT, args.LETTER_NUM)
// 其他业务
return 'success'
};
}

HAT 帽子积木 两种触发方式

默认会无限循环 opcode 所指定的函数

当返回值第一次为 true 时, 执行下面所连接 block

// HAT block  opcode: whenGet
let flag = false
whenGet () {
// 如果 flag 持续为 true, 触发后需要改为 false
if (flag) {
setTimeout(() => {
flag = false
}, 16)
}
return flag
}

手动触发 HAT

https://github.com/LLK/scratch-vm/blob/develop/src/extension-support/extension-metadata.js

HAT block 有一个配置项 isEdgeActivated 默认为 true, 设为 false 将不会后需要用户手动触发

// ... blocks 定义

// opcode 对应函数
func () {
// xxx
// 如果满足 HAT 的触发条件
if (flag) {
this.runtime.startHats('extensionid_hatopcode', {
PARAM: 'txet'
});
}
}
// HAT block 对应函数 将不会执行
hatopcode () {
return true;
}

积木块中插入图片

{
opcode: 'whenDetected',
text: 'image [IMG]',
blockType: BlockType.HAT,
arguments: {
IMG: {
type: ArgumentType.IMAGE,
dataURI: IntelinoImages.SnapIconWhite,
alt: 'white'
}
}
},

COMMAND 积木的返回值

// COMMAND block function

dosth () {
// 点击积木块,下方会弹出提示
return '执行成功'
// 同样也可用于 promise
return Promise.reolve('执行成功')
}

COMMAND 积木的同步等待执行

// COMMAND block function

dosth () {
// 积木块会高亮一秒后执行后续积木
return Promise(resolve => setTimeout(resolve, 1000))
}

内置插件翻译


const formatMessage = require('format-message');

const extensionTranslations = {
'zh-cn': {
'messsageid': '翻译'
}
}

class Ext {
constructor() {
this.setupTranslations()
}

// 在插件初始化时调用, 合并翻译数据
setupTranslations () {
const localeSetup = formatMessage.setup();
if (localeSetup && localeSetup.translations[localeSetup.locale]) {
Object.assign(
localeSetup.translations[localeSetup.locale],
// eslint-disable-next-line no-use-before-define
extensionTranslations[localeSetup.locale]
);
}
}
}

积木调用函数的第二的参数

第一个是 args, 传入的参数,第二个是 utils 一些工具方法

常用的有 target, 用于控制当前的人物

https://github.com/LLK/scratch-vm/blob/develop/src/engine/block-utility.js

插件中使用 video 信息(摄像头数据)

常用于编写tensorflow插件

// 启用摄像头后可以获取video数据
this.runtime.ioDevices.video.enableVideo().then(() => {
this.video = this.runtime.ioDevices.video.provider.video;
// 获取当前帧
const frame = this.runtime.ioDevices.video.getFrame({
format: Video.FORMAT_IMAGE_DATA,
dimensions: Scratch3Pose.DIMENSIONS
});
});

可引入的工具函数

// 转换 常用类型
const Cast = require('../../util/cast');

// 控制触发频率
const RateLimiter = require('../../util/rateLimiter.js');
const SendRateMax = 10;
this._rateLimiter = new RateLimiter(SendRateMax);
if (!this._rateLimiter.okayToSend()) return Promise.resolve();

向舞台绘制图像叠层


displayCtx2stage () {
const {renderer} = this.runtime;
if (!renderer) return;

if (!this._skinId && !this._drawable) {
this._skinId = renderer.createBitmapSkin(new ImageData(480,360), 1);
this._drawable = renderer.createDrawable('video');
renderer.updateDrawableProperties(this._drawable, {
skinId: this._skinId,
// 设置该层透明度
ghost: 20
});
}
if (!this._drawableCreate) {
renderer.updateDrawableProperties(this._drawable, {
visible: true
});
this._drawableCreate = true;
}

// 准备好需绘制的数据
const imageData = myCanvas.getImageData(0, 0, 480, 360);
renderer.updateBitmapSkin(this._skinId, imageData, 1);
this.runtime.requestRedraw();
}

// 隐藏所绘制数据
stopDisplayCtx2stage () {
if (this._skinId !== undefined) {
this.runtime.renderer.updateBitmapSkin(this._skinId, new ImageData(480360), 1);
this.runtime.renderer.updateDrawableProperties(this._drawable, {visible: false});
}
this._renderPreviewFrame = null;
}

Reporter 积木块 disableMonitor

Reporter 积木块有个 disableMonitor 配置,可以关闭前方的 checkbox

复用外围设备连接 UI(microbit 连接形式)

classs Ext {
constructor (runtime) {
this._runtime = runtime;
// 需要提前注册需要设备连接功能
this._runtime.registerPeripheralExtension('extid', this);
}
// 点击扫描后 调用
scan () {
this._runtime.emit(
this._runtime.constructor.PERIPHERAL_LIST_UPDATE,
[{
name: 'deviceName',
peripheralId: 'deviceID',
rssi: -0 // 信号强度
}]
)
}
// 点击设备列表后调用
connect (id) {
// ...
// 连接逻辑
// 连接成功
this.connected = true
this._runtime.emit(
this._runtime.constructor.PERIPHERAL_CONNECTED
);
}
disconnect () {
// ...
// 断开连接的逻辑
// 断开连接
this.connected = false
this._runtime.emit(this._runtime.constructor.PERIPHERAL_DISCONNECTED);
}
reset () {
// 大概是断连业务
}
isConnected () {
return this.connected // 存储一个连接状态
}
getInfo () {
return {
//...
// 显示插件菜单右上角的状态按钮
showStatusButton: true,
blocks: []
}
}
}

为角色增加形象

addCostumeToTarget (args, util) {
const base64 = args.base64;
const _storage = this.runtime.storage;
const _runtime = this.runtime;
const target = util.target;

const BitmapAdapter = SvgRenderer.BitmapAdapter;
const bitmapAdapter = new BitmapAdapter();

return bitmapAdapter
.importBitmap(base64, 'image/png')
.then(dataBuffer => {
const name = 'webcam 1';
const type = _storage.AssetType.ImageBitmap;
const dataFormat = _storage.DataFormat.JPG;

const asset = _storage.createAsset(
type,
dataFormat,
dataBuffer,
null,
true
);

const assetId = asset.assetId;
const md5 = `${assetId}.${dataFormat}`;

const costume = {name, dataFormat, asset, md5, assetId};

return loadCostume(md5, costume, _runtime);
})
.then(costume => {
target.addCostume(costume);
// target.setCostume(target.getCostumes().length - 1);
})
.catch(e => {
console.log('importBitmap to costume');
console.log(e);
return 'importBitmap to costume error';
});
}

// 设为 index 作为形象
setCostumeToTarget (args, util) {
const target = util.target;
target.setCostume(args.index);
}
// 删除形象
deleteTargetCostume (args, util) {
const target = util.target;
target.deleteCostume(args.index);
}