Load extensions when loading a project or sprite

- Accumulate extension info while deserializing JSON
- Install extensions (if any) before installing target(s)
- Merged 'install' steps of the "add sprite" and "load project" paths
This commit is contained in:
Christopher Willis-Ford 2017-11-03 11:17:16 -07:00
parent c5d3e2dbb4
commit 0af3de9bf0
4 changed files with 306 additions and 55 deletions

View file

@ -78,15 +78,16 @@ const flatten = function (blocks) {
* or a list of blocks in an argument (e.g., move [pick random...]). * or a list of blocks in an argument (e.g., move [pick random...]).
* @param {Array.<object>} blockList SB2 JSON-format block list. * @param {Array.<object>} blockList SB2 JSON-format block list.
* @param {Function} getVariableId function to retreive a variable's ID based on name * @param {Function} getVariableId function to retreive a variable's ID based on name
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @return {Array.<object>} Scratch VM-format block list. * @return {Array.<object>} Scratch VM-format block list.
*/ */
const parseBlockList = function (blockList, getVariableId) { const parseBlockList = function (blockList, getVariableId, extensions) {
const resultingList = []; const resultingList = [];
let previousBlock = null; // For setting next. let previousBlock = null; // For setting next.
for (let i = 0; i < blockList.length; i++) { for (let i = 0; i < blockList.length; i++) {
const block = blockList[i]; const block = blockList[i];
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
const parsedBlock = parseBlock(block, getVariableId); const parsedBlock = parseBlock(block, getVariableId, extensions);
if (typeof parsedBlock === 'undefined') continue; if (typeof parsedBlock === 'undefined') continue;
if (previousBlock) { if (previousBlock) {
parsedBlock.parent = previousBlock.id; parsedBlock.parent = previousBlock.id;
@ -104,14 +105,15 @@ const parseBlockList = function (blockList, getVariableId) {
* @param {!object} scripts Scripts object from SB2 JSON. * @param {!object} scripts Scripts object from SB2 JSON.
* @param {!Blocks} blocks Blocks object to load parsed blocks into. * @param {!Blocks} blocks Blocks object to load parsed blocks into.
* @param {Function} getVariableId function to retreive a variable's ID based on name * @param {Function} getVariableId function to retreive a variable's ID based on name
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
*/ */
const parseScripts = function (scripts, blocks, getVariableId) { const parseScripts = function (scripts, blocks, getVariableId, extensions) {
for (let i = 0; i < scripts.length; i++) { for (let i = 0; i < scripts.length; i++) {
const script = scripts[i]; const script = scripts[i];
const scriptX = script[0]; const scriptX = script[0];
const scriptY = script[1]; const scriptY = script[1];
const blockList = script[2]; const blockList = script[2];
const parsedBlockList = parseBlockList(blockList, getVariableId); const parsedBlockList = parseBlockList(blockList, getVariableId, extensions);
if (parsedBlockList[0]) { if (parsedBlockList[0]) {
// Adjust script coordinates to account for // Adjust script coordinates to account for
// larger block size in scratch-blocks. // larger block size in scratch-blocks.
@ -155,12 +157,14 @@ const generateVariableIdGetter = (function () {
/** /**
* Parse a single "Scratch object" and create all its in-memory VM objects. * Parse a single "Scratch object" and create all its in-memory VM objects.
* @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * TODO: parse the "info" section, especially "savedExtensions"
* @param {!Runtime} runtime Runtime object to load all structures into. * @param {!object} object - From-JSON "Scratch object:" sprite, stage, watcher.
* @param {boolean} topLevel Whether this is the top-level object (stage). * @param {!Runtime} runtime - Runtime object to load all structures into.
* @return {?Promise} Promise that resolves to the loaded targets when ready. * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @param {boolean} topLevel - Whether this is the top-level object (stage).
* @return {!Promise.<Array.<Target>>} Promise for the loaded targets when ready, or null for unsupported objects.
*/ */
const parseScratchObject = function (object, runtime, topLevel) { const parseScratchObject = function (object, runtime, extensions, topLevel) {
if (!object.hasOwnProperty('objName')) { if (!object.hasOwnProperty('objName')) {
// Watcher/monitor - skip this object until those are implemented in VM. // Watcher/monitor - skip this object until those are implemented in VM.
// @todo // @todo
@ -228,7 +232,7 @@ const parseScratchObject = function (object, runtime, topLevel) {
// If included, parse any and all scripts/blocks on the object. // If included, parse any and all scripts/blocks on the object.
if (object.hasOwnProperty('scripts')) { if (object.hasOwnProperty('scripts')) {
parseScripts(object.scripts, blocks, getVariableId); parseScripts(object.scripts, blocks, getVariableId, extensions);
} }
if (object.hasOwnProperty('lists')) { if (object.hasOwnProperty('lists')) {
@ -287,7 +291,7 @@ const parseScratchObject = function (object, runtime, topLevel) {
const childrenPromises = []; const childrenPromises = [];
if (object.children) { if (object.children) {
for (let m = 0; m < object.children.length; m++) { for (let m = 0; m < object.children.length; m++) {
childrenPromises.push(parseScratchObject(object.children[m], runtime, false)); childrenPromises.push(parseScratchObject(object.children[m], runtime, extensions, false));
} }
} }
@ -312,31 +316,42 @@ const parseScratchObject = function (object, runtime, topLevel) {
* @param {!object} json SB2-format JSON to load. * @param {!object} json SB2-format JSON to load.
* @param {!Runtime} runtime Runtime object to load all structures into. * @param {!Runtime} runtime Runtime object to load all structures into.
* @param {boolean=} optForceSprite If set, treat as sprite (Sprite2). * @param {boolean=} optForceSprite If set, treat as sprite (Sprite2).
* @return {?Promise} Promise that resolves to the loaded targets when ready. * @return {Promise.<ImportedProject>} Promise that resolves to the loaded targets when ready.
*/ */
const sb2import = function (json, runtime, optForceSprite) { const sb2import = function (json, runtime, optForceSprite) {
return parseScratchObject( const extensions = {
json, extensionIDs: new Set(),
runtime, extensionURLs: new Map()
!optForceSprite };
); return parseScratchObject(json, runtime, extensions, !optForceSprite)
.then(targets => ({
targets,
extensions
}));
}; };
/** /**
* Parse a single SB2 JSON-formatted block and its children. * Parse a single SB2 JSON-formatted block and its children.
* @param {!object} sb2block SB2 JSON-formatted block. * @param {!object} sb2block SB2 JSON-formatted block.
* @param {Function} getVariableId function to retreive a variable's ID based on name * @param {Function} getVariableId function to retrieve a variable's ID based on name
* @return {object} Scratch VM format block. * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @return {object} Scratch VM format block, or null if unsupported object.
*/ */
const parseBlock = function (sb2block, getVariableId) { const parseBlock = function (sb2block, getVariableId, extensions) {
// First item in block object is the old opcode (e.g., 'forward:'). // First item in block object is the old opcode (e.g., 'forward:').
const oldOpcode = sb2block[0]; const oldOpcode = sb2block[0];
// Convert the block using the specMap. See sb2specmap.js. // Convert the block using the specMap. See sb2specmap.js.
if (!oldOpcode || !specMap[oldOpcode]) { if (!oldOpcode || !specMap[oldOpcode]) {
log.warn('Couldn\'t find SB2 block: ', oldOpcode); log.warn('Couldn\'t find SB2 block: ', oldOpcode);
return; return null;
} }
const blockMetadata = specMap[oldOpcode]; const blockMetadata = specMap[oldOpcode];
// If the block is from an extension, record it.
const dotIndex = blockMetadata.opcode.indexOf('.');
if (dotIndex >= 0) {
const extension = blockMetadata.opcode.substring(0, dotIndex);
extensions.extensionIDs.add(extension);
}
// Block skeleton. // Block skeleton.
const activeBlock = { const activeBlock = {
id: uid(), // Generate a new block unique ID. id: uid(), // Generate a new block unique ID.
@ -373,10 +388,10 @@ const parseBlock = function (sb2block, getVariableId) {
let innerBlocks; let innerBlocks;
if (typeof providedArg[0] === 'object' && providedArg[0]) { if (typeof providedArg[0] === 'object' && providedArg[0]) {
// Block list occupies the input. // Block list occupies the input.
innerBlocks = parseBlockList(providedArg, getVariableId); innerBlocks = parseBlockList(providedArg, getVariableId, extensions);
} else { } else {
// Single block occupies the input. // Single block occupies the input.
innerBlocks = [parseBlock(providedArg, getVariableId)]; innerBlocks = [parseBlock(providedArg, getVariableId, extensions)];
} }
let previousBlock = null; let previousBlock = null;
for (let j = 0; j < innerBlocks.length; j++) { for (let j = 0; j < innerBlocks.length; j++) {

View file

@ -21,6 +21,24 @@
* properties. By hand, I matched the opcode name to the 3.0 opcode. * properties. By hand, I matched the opcode name to the 3.0 opcode.
* Finally, I filled in the expected arguments as below. * Finally, I filled in the expected arguments as below.
*/ */
/**
* @typedef {object} SB2SpecMap_blockInfo
* @property {string} opcode - the Scratch 3.0 block opcode. Use 'extensionID.opcode' for extension opcodes.
* @property {Array.<SB2SpecMap_argInfo>} argMap - metadata for this block's arguments.
*/
/**
* @typedef {object} SB2SpecMap_argInfo
* @property {string} type - the type of this arg (such as 'input' or 'field')
* @property {string} inputOp - the scratch-blocks shadow type for this arg
* @property {string} inputName - the name this argument will take when provided to the block implementation
*/
/**
* Mapping of Scratch 2.0 opcode to Scratch 3.0 block metadata.
* @type {object.<SB2SpecMap_blockInfo>}
*/
const specMap = { const specMap = {
'forward:': { 'forward:': {
opcode: 'motion_movesteps', opcode: 'motion_movesteps',
@ -1376,4 +1394,179 @@ const specMap = {
argMap: [] argMap: []
} }
}; };
/**
* Add to the specMap entries for an opcode from a Scratch 2.0 extension. Two entries will be made with the same
* metadata; this is done to support projects saved by both older and newer versions of the Scratch 2.0 editor.
* @param {string} sb2Extension - the Scratch 2.0 name of the extension
* @param {string} sb2Opcode - the Scratch 2.0 opcode
* @param {SB2SpecMap_blockInfo} blockInfo - the Scratch 3.0 block info
*/
const addExtensionOp = function (sb2Extension, sb2Opcode, blockInfo) {
/**
* This string separates the name of an extension and the name of an opcode in more recent Scratch 2.0 projects.
* Earlier projects used '.' as a separator, up until we added the 'LEGO WeDo 2.0' extension...
* @type {string}
*/
const sep = '\u001F'; // Unicode Unit Separator
// make one entry for projects saved by recent versions of the Scratch 2.0 editor
specMap[`${sb2Extension}${sep}${sb2Opcode}`] = blockInfo;
// make a second for projects saved by older versions of the Scratch 2.0 editor
specMap[`${sb2Extension}.${sb2Opcode}`] = blockInfo;
};
const weDo2 = 'LEGO WeDo 2.0';
addExtensionOp(weDo2, 'motorOnFor', {
opcode: 'wedo2.motorOnFor',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'MOTOR_ID'
},
{
type: 'input',
inputOp: 'math_number',
inputName: 'DURATION'
}
]
});
addExtensionOp(weDo2, 'motorOn', {
opcode: 'wedo2.motorOn',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'MOTOR_ID'
}
]
});
addExtensionOp(weDo2, 'motorOff', {
opcode: 'wedo2.motorOff',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'MOTOR_ID'
}
]
});
addExtensionOp(weDo2, 'startMotorPower', {
opcode: 'wedo2.startMotorPower',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'MOTOR_ID'
},
{
type: 'input',
inputOp: 'math_number',
inputName: 'POWER'
}
]
});
addExtensionOp(weDo2, 'setMotorDirection', {
opcode: 'wedo2.setMotorDirection',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'MOTOR_ID'
},
{
type: 'input',
inputOp: 'text',
inputName: 'DIRECTION'
}
]
});
addExtensionOp(weDo2, 'setLED', {
opcode: 'wedo2.setLightHue',
argMap: [
{
type: 'input',
inputOp: 'math_number',
inputName: 'HUE'
}
]
});
addExtensionOp(weDo2, 'playNote', {
opcode: 'wedo2.playNoteFor',
argMap: [
{
type: 'input',
inputOp: 'math_number',
inputName: 'NOTE'
},
{
type: 'input',
inputOp: 'math_number',
inputName: 'DURATION'
}
]
});
addExtensionOp(weDo2, 'whenDistance', {
opcode: 'wedo2.whenDistance',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'OP'
},
{
type: 'input',
inputOp: 'math_number',
inputName: 'REFERENCE'
}
]
});
addExtensionOp(weDo2, 'whenTilted', {
opcode: 'wedo2.whenTilted',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'DIRECTION'
}
]
});
addExtensionOp(weDo2, 'getDistance', {
opcode: 'wedo2.motorOn'
});
addExtensionOp(weDo2, 'isTilted', {
opcode: 'wedo2.motorOn',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'DIRECTION'
}
]
});
addExtensionOp(weDo2, 'getTilt', {
opcode: 'getTiltAngle',
argMap: [
{
type: 'input',
inputOp: 'text',
inputName: 'DIRECTION'
}
]
});
module.exports = specMap; module.exports = specMap;

View file

@ -13,6 +13,18 @@ const List = require('../engine/list');
const {loadCostume} = require('../import/load-costume.js'); const {loadCostume} = require('../import/load-costume.js');
const {loadSound} = require('../import/load-sound.js'); const {loadSound} = require('../import/load-sound.js');
/**
* @typedef {object} ImportedProject
* @property {Array.<Target>} targets - the imported Scratch 3.0 target objects.
* @property {ImportedExtensionsInfo} extensionsInfo - the ID of each extension actually used by this project.
*/
/**
* @typedef {object} ImportedExtensionsInfo
* @property {Set.<string>} extensionIDs - the ID of each extension actually in use by blocks in this project.
* @property {Map.<string, string>} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs.
*/
/** /**
* Serializes the specified VM runtime. * Serializes the specified VM runtime.
* @param {!Runtime} runtime VM runtime instance to be serialized. * @param {!Runtime} runtime VM runtime instance to be serialized.
@ -41,13 +53,14 @@ const serialize = function (runtime) {
* Parse a single "Scratch object" and create all its in-memory VM objects. * Parse a single "Scratch object" and create all its in-memory VM objects.
* @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher.
* @param {!Runtime} runtime Runtime object to load all structures into. * @param {!Runtime} runtime Runtime object to load all structures into.
* @return {?Target} Target created (stage or sprite). * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @return {!Promise.<Target>} Promise for the target created (stage or sprite), or null for unsupported objects.
*/ */
const parseScratchObject = function (object, runtime) { const parseScratchObject = function (object, runtime, extensions) {
if (!object.hasOwnProperty('name')) { if (!object.hasOwnProperty('name')) {
// Watcher/monitor - skip this object until those are implemented in VM. // Watcher/monitor - skip this object until those are implemented in VM.
// @todo // @todo
return; return Promise.resolve(null);
} }
// Blocks container for this object. // Blocks container for this object.
const blocks = new Blocks(); const blocks = new Blocks();
@ -61,7 +74,14 @@ const parseScratchObject = function (object, runtime) {
} }
if (object.hasOwnProperty('blocks')) { if (object.hasOwnProperty('blocks')) {
for (const blockId in object.blocks) { for (const blockId in object.blocks) {
blocks.createBlock(object.blocks[blockId]); const blockJSON = object.blockType[blockId];
blocks.createBlock(blockJSON);
const dotIndex = blockJSON.opcode.indexOf('.');
if (dotIndex >= 0) {
const extensionId = blockJSON.opcode.substring(0, dotIndex);
extensions.extensionIDs.add(extensionId);
}
} }
// console.log(blocks); // console.log(blocks);
} }
@ -155,14 +175,23 @@ const parseScratchObject = function (object, runtime) {
}; };
/** /**
* Deserializes the specified representation of a VM runtime and loads it into * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance.
* the provided runtime instance. * TODO: parse extension info (also, design extension info storage...)
* @param {object} json JSON representation of a VM runtime. * @param {object} json - JSON representation of a VM runtime.
* @param {Runtime} runtime Runtime instance * @param {Runtime} runtime - Runtime instance
* @returns {Promise} Promise that resolves to the list of targets after the project is deserialized * @returns {Promise.<ImportedProject>} Promise that resolves to the list of targets after the project is deserialized
*/ */
const deserialize = function (json, runtime) { const deserialize = function (json, runtime) {
return Promise.all((json.targets || []).map(target => parseScratchObject(target, runtime))); const extensions = {
extensionIDs: new Set(),
extensionURLs: new Map()
};
return Promise.all(
(json.targets || []).map(target => parseScratchObject(target, runtime, extensions))
).then(targets => ({
targets,
extensions
}));
}; };
module.exports = { module.exports = {

View file

@ -33,7 +33,7 @@ class VirtualMachine extends EventEmitter {
/** /**
* The "currently editing"/selected target ID for the VM. * The "currently editing"/selected target ID for the VM.
* Block events from any Blockly workspace are routed to this target. * Block events from any Blockly workspace are routed to this target.
* @type {!string} * @type {Target}
*/ */
this.editingTarget = null; this.editingTarget = null;
// Runtime emits are passed along as VM emits. // Runtime emits are passed along as VM emits.
@ -228,19 +228,41 @@ class VirtualMachine extends EventEmitter {
deserializer = sb2; deserializer = sb2;
} }
return deserializer.deserialize(json, this.runtime).then(targets => { return deserializer.deserialize(json, this.runtime)
this.clear(); .then(({targets, extensions}) =>
for (let n = 0; n < targets.length; n++) { this.installTargets(targets, extensions, true));
if (targets[n] !== null) { }
this.runtime.targets.push(targets[n]);
targets[n].updateAllDrawableProperties(); /**
} * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets.
* @param {Array.<Target>} targets - the targets to be installed
* @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets
* @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite.
*/
installTargets (targets, extensions, wholeProject) {
const extensionPromises = [];
extensions.extensionIDs.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL));
} }
});
targets = targets.filter(target => !!target);
Promise.all(extensionPromises).then(() => {
if (wholeProject) {
this.clear();
}
targets.forEach(target => {
this.runtime.targets.push(target);
(/** @type RenderedTarget */ target).updateAllDrawableProperties();
});
// Select the first target for editing, e.g., the first sprite. // Select the first target for editing, e.g., the first sprite.
if (this.runtime.targets.length > 1) { if (wholeProject && (targets.length > 1)) {
this.editingTarget = this.runtime.targets[1]; this.editingTarget = targets[1];
} else { } else {
this.editingTarget = this.runtime.targets[0]; this.editingTarget = targets[0];
} }
// Update the VM user's knowledge of targets and blocks on the workspace. // Update the VM user's knowledge of targets and blocks on the workspace.
@ -267,17 +289,9 @@ class VirtualMachine extends EventEmitter {
return; return;
} }
// Select new sprite. return sb2.deserialize(json, this.runtime, true)
return sb2.deserialize(json, this.runtime, true).then(targets => { .then(({targets, extensions}) =>
this.runtime.targets.push(targets[0]); this.installTargets(targets, extensions, false));
this.editingTarget = targets[0];
this.editingTarget.updateAllDrawableProperties();
// Update the VM user's knowledge of targets and blocks on the workspace.
this.emitTargetsUpdate();
this.emitWorkspaceUpdate();
this.runtime.setEditingTarget(this.editingTarget);
});
} }
/** /**