Merge pull request from cwillisf/builtin-extensions

Builtin extensions
This commit is contained in:
Chris Willis-Ford 2017-10-13 09:48:56 -07:00 committed by GitHub
commit 7051ccfd69
9 changed files with 373 additions and 69 deletions

View file

@ -31,6 +31,7 @@
"babel-loader": "^7.0.0",
"babel-preset-es2015": "^6.24.1",
"copy-webpack-plugin": "4.0.1",
"escape-html": "1.0.3",
"eslint": "^4.5.0",
"eslint-config-scratch": "^4.0.0",
"expose-loader": "0.7.3",

View file

@ -1,3 +1,5 @@
const ArgumentType = require('../extension-support/argument-type');
const BlockType = require('../extension-support/block-type');
const Cast = require('../util/cast');
const Clone = require('../util/clone');
const Color = require('../util/color');
@ -235,24 +237,116 @@ class Scratch3PenBlocks {
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
* @returns {object} metadata for this extension and its blocks.
*/
getPrimitives () {
getInfo () {
return {
pen_clear: this.clear,
pen_stamp: this.stamp,
pen_pendown: this.penDown,
pen_penup: this.penUp,
pen_setpencolortocolor: this.setPenColorToColor,
pen_changepencolorby: this.changePenHueBy,
pen_setpencolortonum: this.setPenHueToNumber,
pen_changepenshadeby: this.changePenShadeBy,
pen_setpenshadeto: this.setPenShadeToNumber,
pen_changepensizeby: this.changePenSizeBy,
pen_setpensizeto: this.setPenSizeTo,
pen_changepentransparencyby: this.changePenTransparencyBy,
pen_setpentransparencyto: this.setPenTransparencyTo
id: 'pen',
name: 'Pen',
blocks: [
{
opcode: 'clear',
blockType: BlockType.COMMAND,
arguments: {
NUM1: {
type: ArgumentType.NUMBER
},
NUM2: {
type: ArgumentType.NUMBER
}
}
},
{
opcode: 'stamp',
blockType: BlockType.COMMAND
},
{
opcode: 'penDown',
blockType: BlockType.COMMAND,
text: 'pen down'
},
{
opcode: 'penUp',
blockType: BlockType.COMMAND,
text: 'pen up'
},
{
opcode: 'setPenColorToColor',
blockType: BlockType.COMMAND,
text: 'set pen color to [COLOR]',
arguments: {
COLOR: {
type: ArgumentType.COLOR
}
}
},
{
opcode: 'changePenHueBy',
blockType: BlockType.COMMAND,
text: 'change pen color by [COLOR]',
arguments: {
COLOR: {
type: ArgumentType.NUMBER,
defaultValue: 10
}
}
},
{
opcode: 'setPenHueToNumber',
blockType: BlockType.COMMAND,
text: 'set pen color to [COLOR]',
arguments: {
COLOR: {
type: ArgumentType.NUMBER,
defaultValue: 0
}
}
},
{
opcode: 'changePenShadeBy',
blockType: BlockType.COMMAND,
text: 'change pen shade by [SHADE]',
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
defaultValue: 10
}
}
},
{
opcode: 'setPenShadeToNumber',
blockType: BlockType.COMMAND,
text: 'set pen shade to [SHADE]',
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'changePenSizeBy',
blockType: BlockType.COMMAND,
text: 'change pen size by [SIZE]',
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'setPenSizeTo',
blockType: BlockType.COMMAND,
text: 'set pen size to [SIZE]',
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
}
]
};
}

View file

@ -1,3 +1,5 @@
const ArgumentType = require('../extension-support/argument-type');
const BlockType = require('../extension-support/block-type');
const color = require('../util/color');
const log = require('../util/log');
@ -371,9 +373,9 @@ const TiltDirection = {
class Scratch3WeDo2Blocks {
/**
* @return {string} - the name of this extension.
* @return {string} - the ID of this extension.
*/
static get EXTENSION_NAME () {
static get EXTENSION_ID () {
return 'wedo2';
}
@ -395,7 +397,184 @@ class Scratch3WeDo2Blocks {
*/
this.runtime = runtime;
this.runtime.HACK_WeDo2Blocks = this;
this.connect();
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: Scratch3WeDo2Blocks.EXTENSION_ID,
name: 'WeDo 2.0',
blocks: [
{
opcode: 'motorOnFor',
text: 'turn [MOTOR_ID] on for [DURATION] seconds',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
DURATION: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'motorOn',
text: 'turn [MOTOR_ID] on',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
}
}
},
{
opcode: 'motorOff',
text: 'turn [MOTOR_ID] off',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
}
}
},
{
opcode: 'startMotorPower',
text: 'set [MOTOR_ID] power to [POWER]',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
POWER: {
type: ArgumentType.NUMBER,
defaultValue: 100
}
}
},
{
opcode: 'setMotorDirection',
text: 'set [MOTOR_ID] direction to [DIRECTION]',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
DIRECTION: {
type: ArgumentType.STRING,
menu: 'motorDirection',
defaultValue: MotorDirection.FORWARD
}
}
},
{
opcode: 'setLightHue',
text: 'set light color to [HUE]',
blockType: BlockType.COMMAND,
arguments: {
HUE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'playNoteFor',
text: 'play note [NOTE] for [DURATION] seconds',
blockType: BlockType.COMMAND,
arguments: {
NOTE: {
type: ArgumentType.NUMBER, // TODO: ArgumentType.MIDI_NOTE?
defaultValue: 60
},
DURATION: {
type: ArgumentType.NUMBER,
defaultValue: 0.5
}
}
},
{
opcode: 'whenDistance',
text: 'when distance [OP] [REFERENCE]',
blockType: BlockType.HAT,
arguments: {
OP: {
type: ArgumentType.STRING,
menu: 'lessMore',
defaultValue: '<'
},
REFERENCE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'whenTilted',
text: 'when tilted [DIRECTION]',
func: 'isTilted',
blockType: BlockType.HAT,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: TiltDirection.ANY
}
}
},
{
opcode: 'getDistance',
text: 'distance',
blockType: BlockType.REPORTER
},
{
opcode: 'isTilted',
text: 'tilted [DIRECTION]?',
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: TiltDirection.ANY
}
}
},
{
opcode: 'getTiltAngle',
text: 'tilt angle [DIRECTION]',
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirection',
defaultValue: TiltDirection.UP
}
}
}
],
menus: {
motorID: [MotorID.DEFAULT, MotorID.A, MotorID.B, MotorID.ALL],
motorDirection: [MotorDirection.FORWARD, MotorDirection.BACKWARD, MotorDirection.REVERSE],
tiltDirection: [TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT],
tiltDirectionAny:
[TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT, TiltDirection.ANY],
lessMore: ['<', '>']
}
};
}
/**
@ -407,7 +586,7 @@ class Scratch3WeDo2Blocks {
}
const deviceManager = this.runtime.ioDevices.deviceManager;
const finder = this._finder =
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_NAME, WeDo2.DEVICE_TYPE);
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_ID, WeDo2.DEVICE_TYPE);
this._finder.promise.then(
socket => {
if (this._finder === finder) {
@ -427,27 +606,6 @@ class Scratch3WeDo2Blocks {
});
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
wedo2_motorOnFor: this.motorOnFor,
wedo2_motorOn: this.motorOn,
wedo2_motorOff: this.motorOff,
wedo2_startMotorPower: this.startMotorPower,
wedo2_setMotorDirection: this.setMotorDirection,
wedo2_setLightHue: this.setLightHue,
wedo2_playNoteFor: this.playNoteFor,
wedo2_whenDistance: this.whenDistance,
wedo2_whenTilted: this.whenTilted,
wedo2_getDistance: this.getDistance,
wedo2_isTilted: this.isTilted,
wedo2_getTiltAngle: this.getTiltAngle
};
}
/**
* Turn specified motor(s) on for a specified duration.
* @param {object} args - the block's arguments.

View file

@ -91,6 +91,16 @@ class SharedDispatch {
}
}
/**
* Check if a particular service lives on another worker.
* @param {string} service - the service to check.
* @returns {boolean} - true if the service is remote (calls must cross a Worker boundary), false otherwise.
* @private
*/
_isRemoteService (service) {
return this._getServiceProvider(service).isRemote;
}
/**
* Like {@link call}, but force the call to be posted through a particular communication channel.
* @param {object} provider - send the call through this object's `postMessage` function.

View file

@ -1,5 +1,6 @@
const EventEmitter = require('events');
const {OrderedMap} = require('immutable');
const escapeHtml = require('escape-html');
const ArgumentType = require('../extension-support/argument-type');
const Blocks = require('./blocks');
@ -19,12 +20,10 @@ const defaultBlockPackages = {
scratch3_looks: require('../blocks/scratch3_looks'),
scratch3_motion: require('../blocks/scratch3_motion'),
scratch3_operators: require('../blocks/scratch3_operators'),
scratch3_pen: require('../blocks/scratch3_pen'),
scratch3_sound: require('../blocks/scratch3_sound'),
scratch3_sensing: require('../blocks/scratch3_sensing'),
scratch3_data: require('../blocks/scratch3_data'),
scratch3_procedures: require('../blocks/scratch3_procedures'),
scratch3_wedo2: require('../blocks/scratch3_wedo2')
scratch3_procedures: require('../blocks/scratch3_procedures')
};
/**
@ -469,7 +468,9 @@ class Runtime extends EventEmitter {
const argInfo = blockInfo.arguments[placeholder] || {};
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? '' : argInfo.defaultValue.toString());
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
'' :
escapeHtml(argInfo.defaultValue.toString()));
// <value> is the ScratchBlocks name for a block input.
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.

View file

@ -3,6 +3,16 @@ const log = require('../util/log');
const BlockType = require('./block-type');
// These extensions are currently built into the VM repository but should not be loaded at startup.
// TODO: move these out into a separate repository?
// TODO: change extension spec so that library info, including extension ID, can be collected through static methods
const Scratch3PenBlocks = require('../blocks/scratch3_pen');
const Scratch3WeDo2Blocks = require('../blocks/scratch3_wedo2');
const builtinExtensions = {
pen: Scratch3PenBlocks,
wedo2: Scratch3WeDo2Blocks
};
/**
* @typedef {object} ArgumentInfo - Information about an extension block argument
* @property {ArgumentType} type - the type of value this argument can take
@ -39,7 +49,7 @@ const BlockType = require('./block-type');
*/
class ExtensionManager {
constructor () {
constructor (runtime) {
/**
* The ID number to provide to the next extension worker.
* @type {int}
@ -60,17 +70,30 @@ class ExtensionManager {
*/
this.pendingWorkers = [];
/**
* Keep a reference to the runtime so we can construct internal extension objects.
* TODO: remove this in favor of extensions accessing the runtime as a service.
* @type {Runtime}
*/
this.runtime = runtime;
dispatch.setService('extensions', this).catch(e => {
log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`);
});
}
/**
* Load an extension by URL
* @param {string} extensionURL - the URL for the extension to load
* Load an extension by URL or internal extension ID
* @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure
*/
loadExtensionURL (extensionURL) {
if (builtinExtensions.hasOwnProperty(extensionURL)) {
const extension = builtinExtensions[extensionURL];
const extensionInstance = new extension(this.runtime);
return this._registerInternalExtension(extensionInstance);
}
return new Promise((resolve, reject) => {
// If we `require` this at the global level it breaks non-webpack targets, including tests
const ExtensionWorker = require('worker-loader?name=extension-worker.js!./extension-worker');
@ -175,7 +198,17 @@ class ExtensionManager {
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
blockInfo.text = blockInfo.text || blockInfo.opcode;
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
/**
* This is only here because the VM performs poorly when blocks return promises.
* @TODO make it possible for the VM to resolve a promise and continue during the same frame.
*/
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
const serviceObject = dispatch.services[serviceName];
blockInfo.func = serviceObject[blockInfo.func].bind(serviceObject);
}
return blockInfo;
}
}

View file

@ -500,27 +500,27 @@ const specMap = {
]
},
'clearPenTrails': {
opcode: 'pen_clear',
opcode: 'pen.clear',
argMap: [
]
},
'stampCostume': {
opcode: 'pen_stamp',
opcode: 'pen.stamp',
argMap: [
]
},
'putPenDown': {
opcode: 'pen_pendown',
opcode: 'pen.penDown',
argMap: [
]
},
'putPenUp': {
opcode: 'pen_penup',
opcode: 'pen.penUp',
argMap: [
]
},
'penColor:': {
opcode: 'pen_setpencolortocolor',
opcode: 'pen.setPenColorToColor',
argMap: [
{
type: 'input',
@ -530,7 +530,7 @@ const specMap = {
]
},
'changePenHueBy:': {
opcode: 'pen_changepencolorby',
opcode: 'pen.changePenHueBy',
argMap: [
{
type: 'input',
@ -540,7 +540,7 @@ const specMap = {
]
},
'setPenHueTo:': {
opcode: 'pen_setpencolortonum',
opcode: 'pen.setPenHueToNumber',
argMap: [
{
type: 'input',
@ -550,7 +550,7 @@ const specMap = {
]
},
'changePenShadeBy:': {
opcode: 'pen_changepenshadeby',
opcode: 'pen.changePenShadeBy',
argMap: [
{
type: 'input',
@ -560,7 +560,7 @@ const specMap = {
]
},
'setPenShadeTo:': {
opcode: 'pen_setpenshadeto',
opcode: 'pen.setPenShadeToNumber',
argMap: [
{
type: 'input',
@ -570,7 +570,7 @@ const specMap = {
]
},
'changePenSizeBy:': {
opcode: 'pen_changepensizeby',
opcode: 'pen.changePenSizeBy',
argMap: [
{
type: 'input',
@ -580,7 +580,7 @@ const specMap = {
]
},
'penSize:': {
opcode: 'pen_setpensizeto',
opcode: 'pen.setPenSizeTo',
argMap: [
{
type: 'input',

View file

@ -68,7 +68,7 @@ class VirtualMachine extends EventEmitter {
this.emit(Runtime.EXTENSION_ADDED, blocksInfo);
});
this.extensionManager = new ExtensionManager();
this.extensionManager = new ExtensionManager(this.runtime);
this.blockListener = this.blockListener.bind(this);
this.flyoutBlockListener = this.flyoutBlockListener.bind(this);

View file

@ -1,8 +1,10 @@
const Worker = require('tiny-worker');
const path = require('path');
const test = require('tap').test;
const Scratch3PenBlocks = require('../../src/blocks/scratch3_pen');
const VirtualMachine = require('../../src/index');
const dispatch = require('../../src/dispatch/central-dispatch');
const makeTestStorage = require('../fixtures/make-test-storage');
const extract = require('../fixtures/extract');
@ -10,6 +12,9 @@ const extract = require('../fixtures/extract');
const uri = path.resolve(__dirname, '../fixtures/pen.sb2');
const project = extract(uri);
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
test('pen', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
@ -42,14 +47,16 @@ test('pen', t => {
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
vm.loadProject(project)
.then(() => vm.extensionManager.loadExtensionURL('pen')) /** @TODO: loadProject should load extensions */
.then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});