scratch-vm/src/serialization/sb2.js

921 lines
37 KiB
JavaScript
Raw Normal View History

2016-08-31 13:56:05 -04:00
/**
* @fileoverview
* Partial implementation of an SB2 JSON importer.
* Parses provided JSON and then generates all needed
* scratch-vm runtime structures.
*/
2017-04-17 15:10:04 -04:00
const Blocks = require('../engine/blocks');
const RenderedTarget = require('../sprites/rendered-target');
const Sprite = require('../sprites/sprite');
const Color = require('../util/color');
const log = require('../util/log');
const uid = require('../util/uid');
const StringUtil = require('../util/string-util');
const specMap = require('./sb2_specmap');
2017-04-17 15:10:04 -04:00
const Variable = require('../engine/variable');
const MonitorRecord = require('../engine/monitor-record');
2016-08-31 13:56:05 -04:00
const {loadCostume} = require('../import/load-costume.js');
const {loadSound} = require('../import/load-sound.js');
const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js');
2018-04-20 10:34:17 -04:00
// Constants used during deserialization of an SB2 file
2018-04-20 07:54:02 -04:00
const CORE_EXTENSIONS = [
2018-04-20 10:58:03 -04:00
'argument',
2018-04-20 07:54:02 -04:00
'control',
'data',
'event',
'looks',
2018-04-20 10:58:03 -04:00
'math',
2018-04-20 07:54:02 -04:00
'motion',
'operator',
'procedures',
'sensing',
'sound'
];
/**
* Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n")
* into an argument map. This allows us to provide the expected inputs
* to a mutated procedure call.
* @param {string} procCode Scratch 2.0 procedure string.
* @return {object} Argument map compatible with those in sb2specmap.
*/
const parseProcedureArgMap = function (procCode) {
const argMap = [
{} // First item in list is op string.
];
const INPUT_PREFIX = 'input';
let inputCount = 0;
// Split by %n, %b, %s.
const parts = procCode.split(/(?=[^\\]%[nbs])/);
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (part.substring(0, 1) === '%') {
const argType = part.substring(1, 2);
const arg = {
type: 'input',
inputName: INPUT_PREFIX + (inputCount++)
};
if (argType === 'n') {
arg.inputOp = 'math_number';
} else if (argType === 's') {
arg.inputOp = 'text';
}
argMap.push(arg);
}
}
return argMap;
};
/**
* Generate a list of "argument IDs" for procdefs and caller mutations.
* IDs just end up being `input0`, `input1`, ... which is good enough.
* @param {string} procCode Scratch 2.0 procedure string.
* @return {Array.<string>} Array of argument id strings.
*/
const parseProcedureArgIds = function (procCode) {
return parseProcedureArgMap(procCode)
.map(arg => arg.inputName)
.filter(name => name); // Filter out unnamed inputs which are labels
2017-11-02 17:14:00 -04:00
};
/**
* Flatten a block tree into a block list.
* Children are temporarily stored on the `block.children` property.
* @param {Array.<object>} blocks list generated by `parseBlockList`.
* @return {Array.<object>} Flattened list to be passed to `blocks.createBlock`.
*/
const flatten = function (blocks) {
let finalBlocks = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
finalBlocks.push(block);
if (block.children) {
finalBlocks = finalBlocks.concat(flatten(block.children));
}
delete block.children;
}
return finalBlocks;
};
/**
* Parse any list of blocks from SB2 JSON into a list of VM-format blocks.
* Could be used to parse a top-level script,
* a list of blocks in a branch (e.g., in forever),
* or a list of blocks in an argument (e.g., move [pick random...]).
* @param {Array.<object>} blockList SB2 JSON-format block list.
* @param {Function} addBroadcastMsg function to update broadcast message name map
2017-07-17 12:02:48 -04:00
* @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.
*/
const parseBlockList = function (blockList, addBroadcastMsg, getVariableId, extensions) {
const resultingList = [];
let previousBlock = null; // For setting next.
for (let i = 0; i < blockList.length; i++) {
const block = blockList[i];
// eslint-disable-next-line no-use-before-define
const parsedBlock = parseBlock(block, addBroadcastMsg, getVariableId, extensions);
if (typeof parsedBlock === 'undefined') continue;
if (previousBlock) {
parsedBlock.parent = previousBlock.id;
previousBlock.next = parsedBlock.id;
}
previousBlock = parsedBlock;
resultingList.push(parsedBlock);
}
return resultingList;
};
/**
* Parse a Scratch object's scripts into VM blocks.
* This should only handle top-level scripts that include X, Y coordinates.
* @param {!object} scripts Scripts object from SB2 JSON.
* @param {!Blocks} blocks Blocks object to load parsed blocks into.
* @param {Function} addBroadcastMsg function to update broadcast message name map
2017-07-17 12:02:48 -04:00
* @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, addBroadcastMsg, getVariableId, extensions) {
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
const scriptX = script[0];
const scriptY = script[1];
const blockList = script[2];
const parsedBlockList = parseBlockList(blockList, addBroadcastMsg, getVariableId, extensions);
if (parsedBlockList[0]) {
// Adjust script coordinates to account for
// larger block size in scratch-blocks.
// @todo: Determine more precisely the right formulas here.
parsedBlockList[0].x = scriptX * 1.5;
parsedBlockList[0].y = scriptY * 2.2;
parsedBlockList[0].topLevel = true;
parsedBlockList[0].parent = null;
}
// Flatten children and create add the blocks.
const convertedBlocks = flatten(parsedBlockList);
for (let j = 0; j < convertedBlocks.length; j++) {
blocks.createBlock(convertedBlocks[j]);
}
}
};
2017-07-17 12:02:48 -04:00
/**
* Create a callback for assigning fixed IDs to imported variables
* Generator stores the global variable mapping in a closure
* @param {!string} targetId the id of the target to scope the variable to
* @return {string} variable ID
*/
const generateVariableIdGetter = (function () {
let globalVariableNameMap = {};
const namer = (targetId, name) => `${targetId}-${name}`;
return function (targetId, topLevel) {
// Reset the global variable map if topLevel
if (topLevel) globalVariableNameMap = {};
return function (name) {
if (topLevel) { // Store the name/id pair in the globalVariableNameMap
globalVariableNameMap[name] = namer(targetId, name);
return globalVariableNameMap[name];
}
// Not top-level, so first check the global name map
if (globalVariableNameMap[name]) return globalVariableNameMap[name];
return namer(targetId, name);
};
};
}());
const globalBroadcastMsgStateGenerator = (function () {
let broadcastMsgNameMap = {};
2017-12-15 14:00:53 -05:00
const allBroadcastFields = [];
const emptyStringName = uid();
return function (topLevel) {
if (topLevel) broadcastMsgNameMap = {};
return {
2017-12-15 14:00:53 -05:00
broadcastMsgMapUpdater: function (name, field) {
name = name.toLowerCase();
if (name === '') {
name = emptyStringName;
}
2017-12-01 11:27:54 -05:00
broadcastMsgNameMap[name] = `broadcastMsgId-${name}`;
2017-12-15 14:00:53 -05:00
allBroadcastFields.push(field);
return broadcastMsgNameMap[name];
},
globalBroadcastMsgs: broadcastMsgNameMap,
2017-12-15 14:00:53 -05:00
allBroadcastFields: allBroadcastFields,
emptyMsgName: emptyStringName
};
};
}());
/**
* Parse a single monitor object and create all its in-memory VM objects.
2018-05-09 11:08:49 -04:00
* @param {!object} object - From-JSON "Monitor object"
* @param {!Runtime} runtime - (in/out) Runtime object to load monitor info into.
* @param {!Array.<Target>} targets - Targets have already been parsed.
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
*/
const parseMonitorObject = (object, runtime, targets, extensions) => {
let target = null;
// List blocks don't come in with their target name set.
// Find the target by searching for a target with matching variable name/type.
if (!object.hasOwnProperty('target')) {
for (let i = 0; i < targets.length; i++) {
const currTarget = targets[i];
const listVariables = Object.keys(currTarget.variables).filter(key => {
const variable = currTarget.variables[key];
return variable.type === Variable.LIST_TYPE && variable.name === object.listName;
});
if (listVariables.length > 0) {
target = currTarget; // Keep this target for later use
object.target = currTarget.getName(); // Set target name to normalize with other monitors
}
}
}
2018-05-09 11:08:49 -04:00
// Get the target for this monitor, if not gotten above.
target = target || targets.filter(t => t.getName() === object.target)[0];
if (!target) throw new Error('Cannot create monitor for target that cannot be found by name');
// Create var id getter to make block naming/parsing easier, variables already created.
const getVariableId = generateVariableIdGetter(target.id, false);
// eslint-disable-next-line no-use-before-define
const block = parseBlock(
[object.cmd, object.param], // Scratch 2 monitor blocks only have one param.
null, // `addBroadcastMsg`, not needed for monitor blocks.
getVariableId,
extensions
);
2018-05-09 11:08:49 -04:00
// Monitor blocks have special IDs to match the toolbox obtained from the getId
// function in the runtime.monitorBlocksInfo. Variable monitors, however,
// get their IDs from the variable id they reference.
if (object.cmd === 'getVar:' || object.cmd === 'contentsOfList:') {
block.id = getVariableId(object.param);
2018-05-09 11:08:49 -04:00
} else if (runtime.monitorBlockInfo.hasOwnProperty(block.opcode)) {
block.id = runtime.monitorBlockInfo[block.opcode].getId(target.id, object.param);
}
2018-05-09 11:08:49 -04:00
// Block needs a targetId if it is targetting something other than the stage
block.targetId = target.isStage ? null : target.id;
// Property required for running monitored blocks.
block.isMonitored = object.visible;
// Blocks can be created with children, flatten and add to monitorBlocks.
const newBlocks = flatten([block]);
for (let i = 0; i < newBlocks.length; i++) {
runtime.monitorBlocks.createBlock(newBlocks[i]);
}
// Convert numbered mode into strings for better understandability.
switch (object.mode) {
case 1:
object.mode = 'default';
break;
case 2:
object.mode = 'large';
break;
case 3:
object.mode = 'slider';
break;
}
// Create a monitor record for the runtime's monitorState
runtime.requestAddMonitor(MonitorRecord({
id: block.id,
targetId: block.targetId,
spriteName: block.targetId ? object.target : null,
opcode: block.opcode,
params: runtime.monitorBlocks._getBlockParams(block),
value: '',
mode: object.mode,
sliderMin: object.sliderMin,
sliderMax: object.sliderMax,
x: object.x,
y: object.y,
width: object.width,
height: object.height,
visible: object.visible
}));
};
2016-08-31 13:56:05 -04:00
/**
* Parse a single "Scratch object" and create all its in-memory VM objects.
* TODO: parse the "info" section, especially "savedExtensions"
* @param {!object} object - From-JSON "Scratch object:" sprite, stage, watcher.
* @param {!Runtime} runtime - Runtime object to load all structures into.
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @param {boolean} topLevel - Whether this is the top-level object (stage).
* @param {?object} zip - Optional zipped assets for local file import
* @return {!Promise.<Array.<Target>>} Promise for the loaded targets when ready, or null for unsupported objects.
2016-08-31 13:56:05 -04:00
*/
const parseScratchObject = function (object, runtime, extensions, topLevel, zip) {
2016-08-31 13:56:05 -04:00
if (!object.hasOwnProperty('objName')) {
if (object.hasOwnProperty('listName')) {
// Shim these objects so they can be processed as monitors
object.cmd = 'contentsOfList:';
object.param = object.listName;
object.mode = 'list';
}
// Defer parsing monitors until targets are all parsed
object.deferredMonitor = true;
return Promise.resolve(object);
2016-08-31 13:56:05 -04:00
}
2016-08-31 13:56:05 -04:00
// Blocks container for this object.
2017-04-17 15:10:04 -04:00
const blocks = new Blocks();
2016-08-31 13:56:05 -04:00
// @todo: For now, load all Scratch objects (stage/sprites) as a Sprite.
2018-05-15 22:22:44 -04:00
const sprite = new Sprite(blocks, runtime, topLevel /* whether this sprite is a stge or not */);
2016-08-31 13:56:05 -04:00
// Sprite/stage name from JSON.
if (object.hasOwnProperty('objName')) {
sprite.name = topLevel ? 'Stage' : object.objName;
2016-08-31 13:56:05 -04:00
}
// Costumes from JSON.
2017-04-17 15:10:04 -04:00
const costumePromises = [];
2016-08-31 13:56:05 -04:00
if (object.hasOwnProperty('costumes')) {
2017-04-17 15:10:04 -04:00
for (let i = 0; i < object.costumes.length; i++) {
const costumeSource = object.costumes[i];
const costume = {
name: costumeSource.costumeName,
bitmapResolution: costumeSource.bitmapResolution || 1,
rotationCenterX: costumeSource.rotationCenterX,
rotationCenterY: costumeSource.rotationCenterY,
// TODO we eventually want this next property to be called
// md5ext to reflect what it actually contains, however this
// will be a very extensive change across many repositories
// and should be done carefully and altogether
md5: costumeSource.baseLayerMD5,
skinId: null
};
const md5ext = costumeSource.baseLayerMD5;
const idParts = StringUtil.splitFirst(md5ext, '.');
const md5 = idParts[0];
const ext = idParts[1].toLowerCase();
costume.dataFormat = ext;
costume.assetId = md5;
// If there is no internet connection, or if the asset is not in storage
// for some reason, and we are doing a local .sb2 import, (e.g. zip is provided)
// the file name of the costume should be the baseLayerID followed by the file ext
const assetFileName = `${costumeSource.baseLayerID}.${ext}`;
costumePromises.push(deserializeCostume(costume, runtime, zip, assetFileName)
.then(() => loadCostume(costume.md5, costume, runtime, 2 /* optVersion */)));
2016-08-31 13:56:05 -04:00
}
}
// Sounds from JSON
2017-04-19 17:54:52 -04:00
const soundPromises = [];
2016-10-13 11:35:52 -04:00
if (object.hasOwnProperty('sounds')) {
2017-04-17 15:10:04 -04:00
for (let s = 0; s < object.sounds.length; s++) {
const soundSource = object.sounds[s];
const sound = {
name: soundSource.soundName,
format: soundSource.format,
rate: soundSource.rate,
sampleCount: soundSource.sampleCount,
// TODO we eventually want this next property to be called
// md5ext to reflect what it actually contains, however this
// will be a very extensive change across many repositories
// and should be done carefully and altogether
// (for example, the audio engine currently relies on this
// property to be named 'md5')
md5: soundSource.md5,
data: null
};
const md5ext = soundSource.md5;
const idParts = StringUtil.splitFirst(md5ext, '.');
const md5 = idParts[0];
const ext = idParts[1].toLowerCase();
sound.dataFormat = ext;
sound.assetId = md5;
// If there is no internet connection, or if the asset is not in storage
// for some reason, and we are doing a local .sb2 import, (e.g. zip is provided)
// the file name of the sound should be the soundID (provided from the project.json)
// followed by the file ext
const assetFileName = `${soundSource.soundID}.${ext}`;
soundPromises.push(deserializeSound(sound, runtime, zip, assetFileName)
.then(() => loadSound(sound, runtime)));
}
}
2017-07-17 12:02:48 -04:00
2016-08-31 13:56:05 -04:00
// Create the first clone, and load its run-state from JSON.
2017-04-19 17:54:52 -04:00
const target = sprite.createClone();
2017-07-17 12:02:48 -04:00
const getVariableId = generateVariableIdGetter(target.id, topLevel);
const globalBroadcastMsgObj = globalBroadcastMsgStateGenerator(topLevel);
const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater;
// Load target properties from JSON.
if (object.hasOwnProperty('variables')) {
2017-04-17 15:10:04 -04:00
for (let j = 0; j < object.variables.length; j++) {
const variable = object.variables[j];
const newVariable = new Variable(
2017-07-17 12:02:48 -04:00
getVariableId(variable.name),
variable.name,
2017-11-13 16:55:57 -05:00
Variable.SCALAR_TYPE,
variable.isPersistent
);
newVariable.value = variable.value;
target.variables[newVariable.id] = newVariable;
}
}
2017-07-17 12:02:48 -04:00
// If included, parse any and all scripts/blocks on the object.
if (object.hasOwnProperty('scripts')) {
parseScripts(object.scripts, blocks, addBroadcastMsg, getVariableId, extensions);
2017-07-17 12:02:48 -04:00
}
// Update stage specific blocks (e.g. sprite clicked <=> stage clicked)
blocks.updateTargetSpecificBlocks(topLevel); // topLevel = isStage
if (object.hasOwnProperty('lists')) {
2017-04-17 15:10:04 -04:00
for (let k = 0; k < object.lists.length; k++) {
const list = object.lists[k];
const newVariable = new Variable(
getVariableId(list.listName),
list.listName,
2017-11-13 16:55:57 -05:00
Variable.LIST_TYPE,
2017-11-15 09:17:20 -05:00
false
);
newVariable.value = list.contents;
target.variables[newVariable.id] = newVariable;
}
}
if (object.hasOwnProperty('scratchX')) {
2016-08-31 13:56:05 -04:00
target.x = object.scratchX;
}
if (object.hasOwnProperty('scratchY')) {
2016-08-31 13:56:05 -04:00
target.y = object.scratchY;
}
if (object.hasOwnProperty('direction')) {
2016-08-31 13:56:05 -04:00
target.direction = object.direction;
}
if (object.hasOwnProperty('isDraggable')) {
target.draggable = object.isDraggable;
}
if (object.hasOwnProperty('scale')) {
2016-08-31 13:56:05 -04:00
// SB2 stores as 1.0 = 100%; we use % in the VM.
target.size = object.scale * 100;
}
if (object.hasOwnProperty('visible')) {
2016-08-31 13:56:05 -04:00
target.visible = object.visible;
}
if (object.hasOwnProperty('currentCostumeIndex')) {
2016-09-28 16:43:12 -04:00
target.currentCostume = Math.round(object.currentCostumeIndex);
2016-08-31 13:56:05 -04:00
}
2016-09-28 17:09:04 -04:00
if (object.hasOwnProperty('rotationStyle')) {
if (object.rotationStyle === 'none') {
2016-10-26 11:19:43 -04:00
target.rotationStyle = RenderedTarget.ROTATION_STYLE_NONE;
} else if (object.rotationStyle === 'leftRight') {
2016-10-26 11:19:43 -04:00
target.rotationStyle = RenderedTarget.ROTATION_STYLE_LEFT_RIGHT;
} else if (object.rotationStyle === 'normal') {
2016-10-26 11:19:43 -04:00
target.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND;
2016-09-28 17:09:04 -04:00
}
2016-08-31 13:56:05 -04:00
}
2018-02-27 11:48:23 -05:00
if (object.hasOwnProperty('tempoBPM')) {
target.tempo = object.tempoBPM;
}
2018-04-03 16:36:58 -04:00
if (object.hasOwnProperty('videoAlpha')) {
// SB2 stores alpha as opacity, where 1.0 is opaque.
// We convert to a percentage, and invert it so 100% is full transparency.
target.videoTransparency = 100 - (100 * object.videoAlpha);
}
if (object.hasOwnProperty('info')) {
if (object.info.hasOwnProperty('videoOn')) {
if (object.info.videoOn) {
target.videoState = RenderedTarget.VIDEO_STATE.ON;
}
}
}
target.isStage = topLevel;
2017-04-19 17:54:52 -04:00
Promise.all(costumePromises).then(costumes => {
sprite.costumes = costumes;
});
2017-04-19 17:54:52 -04:00
Promise.all(soundPromises).then(sounds => {
sprite.sounds = sounds;
});
2016-08-31 13:56:05 -04:00
// The stage will have child objects; recursively process them.
2017-04-19 17:54:52 -04:00
const childrenPromises = [];
2016-08-31 13:56:05 -04:00
if (object.children) {
2017-04-19 17:54:52 -04:00
for (let m = 0; m < object.children.length; m++) {
childrenPromises.push(parseScratchObject(object.children[m], runtime, extensions, false, zip));
2016-08-31 13:56:05 -04:00
}
}
2017-04-19 17:54:52 -04:00
return Promise.all(
costumePromises.concat(soundPromises)
).then(() =>
Promise.all(
childrenPromises
).then(children => {
// Need create broadcast msgs as variables after
// all other targets have finished processing.
if (target.isStage) {
const allBroadcastMsgs = globalBroadcastMsgObj.globalBroadcastMsgs;
2017-12-15 14:00:53 -05:00
const allBroadcastMsgFields = globalBroadcastMsgObj.allBroadcastFields;
const oldEmptyMsgName = globalBroadcastMsgObj.emptyMsgName;
if (allBroadcastMsgs[oldEmptyMsgName]) {
// Find a fresh 'messageN'
let currIndex = 1;
while (allBroadcastMsgs[`message${currIndex}`]) {
currIndex += 1;
}
const newEmptyMsgName = `message${currIndex}`;
2017-12-15 14:00:53 -05:00
// Add the new empty message name to the broadcast message
// name map, and assign it the old id.
// Then, delete the old entry in map.
allBroadcastMsgs[newEmptyMsgName] = allBroadcastMsgs[oldEmptyMsgName];
delete allBroadcastMsgs[oldEmptyMsgName];
// Now update all the broadcast message fields with
// the new empty message name.
for (let i = 0; i < allBroadcastMsgFields.length; i++) {
if (allBroadcastMsgFields[i].value === '') {
allBroadcastMsgFields[i].value = newEmptyMsgName;
}
}
}
2017-12-15 14:00:53 -05:00
// Traverse the broadcast message name map and create
// broadcast messages as variables on the stage (which is this
// target).
for (const msgName in allBroadcastMsgs) {
const msgId = allBroadcastMsgs[msgName];
const newMsg = new Variable(
msgId,
msgName,
Variable.BROADCAST_MESSAGE_TYPE,
false
);
target.variables[newMsg.id] = newMsg;
}
}
2017-04-19 17:54:52 -04:00
let targets = [target];
const deferredMonitors = [];
2017-04-19 17:54:52 -04:00
for (let n = 0; n < children.length; n++) {
if (children[n]) {
if (children[n].deferredMonitor) {
deferredMonitors.push(children[n]);
} else {
targets = targets.concat(children[n]);
}
}
}
for (let n = 0; n < deferredMonitors.length; n++) {
parseMonitorObject(deferredMonitors[n], runtime, targets, extensions);
}
return targets;
2017-04-19 17:54:52 -04:00
})
);
};
/**
* Top-level handler. Parse provided JSON,
* and process the top-level object (the stage object).
2017-04-27 17:49:57 -04:00
* @param {!object} json SB2-format JSON to load.
* @param {!Runtime} runtime Runtime object to load all structures into.
2017-02-01 15:59:50 -05:00
* @param {boolean=} optForceSprite If set, treat as sprite (Sprite2).
* @param {?object} zip Optional zipped assets for local file import
* @return {Promise.<ImportedProject>} Promise that resolves to the loaded targets when ready.
*/
const sb2import = function (json, runtime, optForceSprite, zip) {
const extensions = {
extensionIDs: new Set(),
extensionURLs: new Map()
};
return parseScratchObject(json, runtime, extensions, !optForceSprite, zip)
.then(targets => ({
targets,
extensions
}));
};
2016-08-31 13:56:05 -04:00
/**
* Given the sb2 block, inspect the specmap for a translation method or object.
* @param {!object} block a sb2 formatted block
* @return {object} specmap block to parse this opcode
*/
const specMapBlock = function (block) {
const opcode = block[0];
const mapped = opcode && specMap[opcode];
if (!mapped) {
log.warn(`Couldn't find SB2 block: ${opcode}`);
return null;
}
if (typeof mapped === 'function') {
return mapped(block);
}
return mapped;
};
2016-08-31 13:56:05 -04:00
/**
* Parse a single SB2 JSON-formatted block and its children.
2017-02-01 15:59:50 -05:00
* @param {!object} sb2block SB2 JSON-formatted block.
* @param {Function} addBroadcastMsg function to update broadcast message name map
* @param {Function} getVariableId function to retrieve a variable's ID based on name
* @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here.
* @return {object} Scratch VM format block, or null if unsupported object.
2016-08-31 13:56:05 -04:00
*/
const parseBlock = function (sb2block, addBroadcastMsg, getVariableId, extensions) {
const blockMetadata = specMapBlock(sb2block);
if (!blockMetadata) {
return;
2016-08-31 13:56:05 -04:00
}
const oldOpcode = sb2block[0];
2018-04-20 07:54:02 -04:00
// If the block is from an extension, record it.
2018-04-20 07:54:02 -04:00
const index = blockMetadata.opcode.indexOf('_');
const prefix = blockMetadata.opcode.substring(0, index);
if (CORE_EXTENSIONS.indexOf(prefix) === -1) {
if (prefix !== '') extensions.extensionIDs.add(prefix);
}
2018-04-20 07:54:02 -04:00
2016-08-31 13:56:05 -04:00
// Block skeleton.
2017-04-17 15:10:04 -04:00
const activeBlock = {
2016-08-31 13:56:05 -04:00
id: uid(), // Generate a new block unique ID.
opcode: blockMetadata.opcode, // Converted, e.g. "motion_movesteps".
inputs: {}, // Inputs to this block and the blocks they point to.
fields: {}, // Fields on this block and their values.
next: null, // Next block.
2016-08-31 13:56:05 -04:00
shadow: false, // No shadow blocks in an SB2 by default.
children: [] // Store any generated children, flattened in `flatten`.
};
2016-10-13 13:11:26 -04:00
// For a procedure call, generate argument map from proc string.
if (oldOpcode === 'call') {
2016-10-13 13:11:26 -04:00
blockMetadata.argMap = parseProcedureArgMap(sb2block[1]);
}
2016-08-31 13:56:05 -04:00
// Look at the expected arguments in `blockMetadata.argMap.`
// The basic problem here is to turn positional SB2 arguments into
// non-positional named Scratch VM arguments.
2017-04-17 15:10:04 -04:00
for (let i = 0; i < blockMetadata.argMap.length; i++) {
const expectedArg = blockMetadata.argMap[i];
const providedArg = sb2block[i + 1]; // (i = 0 is opcode)
// Whether the input is obscuring a shadow.
2017-04-17 15:10:04 -04:00
let shadowObscured = false;
2016-08-31 13:56:05 -04:00
// Positional argument is an input.
if (expectedArg.type === 'input') {
2016-08-31 13:56:05 -04:00
// Create a new block and input metadata.
2017-04-17 15:10:04 -04:00
const inputUid = uid();
2016-08-31 13:56:05 -04:00
activeBlock.inputs[expectedArg.inputName] = {
name: expectedArg.inputName,
block: null,
shadow: null
2016-08-31 13:56:05 -04:00
};
if (typeof providedArg === 'object' && providedArg) {
2016-08-31 13:56:05 -04:00
// Block or block list occupies the input.
let innerBlocks;
if (typeof providedArg[0] === 'object' && providedArg[0]) {
2016-08-31 13:56:05 -04:00
// Block list occupies the input.
innerBlocks = parseBlockList(providedArg, addBroadcastMsg, getVariableId, extensions);
2016-08-31 13:56:05 -04:00
} else {
// Single block occupies the input.
innerBlocks = [parseBlock(providedArg, addBroadcastMsg, getVariableId, extensions)];
2016-08-31 13:56:05 -04:00
}
2017-04-17 15:10:04 -04:00
let previousBlock = null;
for (let j = 0; j < innerBlocks.length; j++) {
if (j === 0) {
innerBlocks[j].parent = activeBlock.id;
} else {
innerBlocks[j].parent = previousBlock;
}
previousBlock = innerBlocks[j].id;
}
// Obscures any shadow.
shadowObscured = true;
activeBlock.inputs[expectedArg.inputName].block = (
innerBlocks[0].id
);
2016-08-31 13:56:05 -04:00
activeBlock.children = (
activeBlock.children.concat(innerBlocks)
);
}
// Generate a shadow block to occupy the input.
if (!expectedArg.inputOp) {
// No editable shadow input; e.g., for a boolean.
continue;
}
// Each shadow has a field generated for it automatically.
// Value to be filled in the field.
2017-04-17 15:10:04 -04:00
let fieldValue = providedArg;
// Shadows' field names match the input name, except for these:
2017-04-17 15:10:04 -04:00
let fieldName = expectedArg.inputName;
if (expectedArg.inputOp === 'math_number' ||
expectedArg.inputOp === 'math_whole_number' ||
expectedArg.inputOp === 'math_positive_number' ||
expectedArg.inputOp === 'math_integer' ||
expectedArg.inputOp === 'math_angle') {
fieldName = 'NUM';
// Fields are given Scratch 2.0 default values if obscured.
if (shadowObscured) {
fieldValue = 10;
}
} else if (expectedArg.inputOp === 'text') {
fieldName = 'TEXT';
if (shadowObscured) {
fieldValue = '';
2016-08-31 13:56:05 -04:00
}
} else if (expectedArg.inputOp === 'colour_picker') {
// Convert SB2 color to hex.
fieldValue = Color.decimalToHex(providedArg);
fieldName = 'COLOUR';
if (shadowObscured) {
fieldValue = '#990000';
}
} else if (expectedArg.inputOp === 'event_broadcast_menu') {
fieldName = 'BROADCAST_OPTION';
if (shadowObscured) {
fieldValue = '';
}
} else if (expectedArg.inputOp === 'music.menu.DRUM') {
if (shadowObscured) {
fieldValue = 1;
}
} else if (expectedArg.inputOp === 'music.menu.INSTRUMENT') {
if (shadowObscured) {
fieldValue = 1;
}
} else if (expectedArg.inputOp === 'videoSensing.menu.ATTRIBUTE') {
if (shadowObscured) {
fieldValue = 'motion';
}
} else if (expectedArg.inputOp === 'videoSensing.menu.SUBJECT') {
if (shadowObscured) {
fieldValue = 'this sprite';
}
} else if (expectedArg.inputOp === 'videoSensing.menu.VIDEO_STATE') {
if (shadowObscured) {
fieldValue = 'on';
}
} else if (shadowObscured) {
// Filled drop-down menu.
fieldValue = '';
}
2017-04-17 15:10:04 -04:00
const fields = {};
fields[fieldName] = {
2017-12-15 14:00:53 -05:00
name: fieldName,
value: fieldValue
};
// event_broadcast_menus have some extra properties to add to the
// field and a different value than the rest
if (expectedArg.inputOp === 'event_broadcast_menu') {
// Need to update the broadcast message name map with
// the value of this field.
// Also need to provide the fields[fieldName] object,
// so that we can later update its value property, e.g.
// if sb2 message name is empty string, we will later
// replace this field's value with messageN
// once we can traverse through all the existing message names
// and come up with a fresh messageN.
const broadcastId = addBroadcastMsg(fieldValue, fields[fieldName]);
fields[fieldName].id = broadcastId;
fields[fieldName].variableType = expectedArg.variableType;
}
activeBlock.children.push({
id: inputUid,
opcode: expectedArg.inputOp,
inputs: {},
fields: fields,
next: null,
topLevel: false,
parent: activeBlock.id,
shadow: true
});
activeBlock.inputs[expectedArg.inputName].shadow = inputUid;
// If no block occupying the input, alias to the shadow.
if (!activeBlock.inputs[expectedArg.inputName].block) {
activeBlock.inputs[expectedArg.inputName].block = inputUid;
2016-08-31 13:56:05 -04:00
}
} else if (expectedArg.type === 'field') {
2016-08-31 13:56:05 -04:00
// Add as a field on this block.
activeBlock.fields[expectedArg.fieldName] = {
name: expectedArg.fieldName,
value: providedArg
};
2017-07-17 12:02:48 -04:00
2017-11-13 14:24:30 -05:00
if (expectedArg.fieldName === 'VARIABLE' || expectedArg.fieldName === 'LIST') {
2017-07-17 12:02:48 -04:00
// Add `id` property to variable fields
activeBlock.fields[expectedArg.fieldName].id = getVariableId(providedArg);
} else if (expectedArg.fieldName === 'BROADCAST_OPTION') {
2017-12-15 14:00:53 -05:00
// Add the name in this field to the broadcast msg name map.
// Also need to provide the fields[fieldName] object,
// so that we can later update its value property, e.g.
// if sb2 message name is empty string, we will later
// replace this field's value with messageN
// once we can traverse through all the existing message names
// and come up with a fresh messageN.
const broadcastId = addBroadcastMsg(providedArg, activeBlock.fields[expectedArg.fieldName]);
activeBlock.fields[expectedArg.fieldName].id = broadcastId;
2017-07-17 12:02:48 -04:00
}
2017-11-13 14:24:30 -05:00
const varType = expectedArg.variableType;
if (typeof varType === 'string') {
activeBlock.fields[expectedArg.fieldName].variableType = varType;
}
2016-08-31 13:56:05 -04:00
}
}
// Updates for blocks that have new menus (e.g. in Looks)
switch (oldOpcode) {
case 'comeToFront':
activeBlock.fields.FRONT_BACK = {
name: 'FRONT_BACK',
value: 'front'
};
break;
case 'goBackByLayers:':
activeBlock.fields.FORWARD_BACKWARD = {
name: 'FORWARD_BACKWARD',
value: 'backward'
};
break;
case 'backgroundIndex':
activeBlock.fields.NUMBER_NAME = {
name: 'NUMBER_NAME',
value: 'number'
};
break;
case 'sceneName':
activeBlock.fields.NUMBER_NAME = {
name: 'NUMBER_NAME',
value: 'name'
};
break;
case 'costumeIndex':
activeBlock.fields.NUMBER_NAME = {
name: 'NUMBER_NAME',
value: 'number'
};
break;
}
// Special cases to generate mutations.
if (oldOpcode === 'stopScripts') {
2016-10-13 13:11:26 -04:00
// Mutation for stop block: if the argument is 'other scripts',
// the block needs a next connection.
if (sb2block[1] === 'other scripts in sprite' ||
sb2block[1] === 'other scripts in stage') {
2016-10-13 13:11:26 -04:00
activeBlock.mutation = {
tagName: 'mutation',
hasnext: 'true',
children: []
};
}
} else if (oldOpcode === 'procDef') {
2016-10-13 13:11:26 -04:00
// Mutation for procedure definition:
// store all 2.0 proc data.
2017-04-17 15:10:04 -04:00
const procData = sb2block.slice(1);
// Create a new block and input metadata.
const inputUid = uid();
const inputName = 'custom_block';
activeBlock.inputs[inputName] = {
name: inputName,
block: inputUid,
shadow: inputUid
2016-10-13 13:11:26 -04:00
};
activeBlock.children = [{
id: inputUid,
opcode: 'procedures_prototype',
inputs: {},
fields: {},
next: null,
shadow: true,
children: [],
mutation: {
tagName: 'mutation',
proccode: procData[0], // e.g., "abc %n %b %s"
argumentnames: JSON.stringify(procData[1]), // e.g. ['arg1', 'arg2']
argumentids: JSON.stringify(parseProcedureArgIds(procData[0])),
argumentdefaults: JSON.stringify(procData[2]), // e.g., [1, 'abc']
warp: procData[3], // Warp mode, e.g., true/false.
children: []
}
}];
} else if (oldOpcode === 'call') {
2016-10-13 13:11:26 -04:00
// Mutation for procedure call:
// string for proc code (e.g., "abc %n %b %s").
activeBlock.mutation = {
tagName: 'mutation',
children: [],
proccode: sb2block[1],
argumentids: JSON.stringify(parseProcedureArgIds(sb2block[1]))
2016-10-13 13:11:26 -04:00
};
} else if (oldOpcode === 'getParam') {
// Assign correct opcode based on the block shape.
switch (sb2block[2]) {
case 'r':
activeBlock.opcode = 'argument_reporter_string_number';
break;
case 'b':
activeBlock.opcode = 'argument_reporter_boolean';
break;
}
}
2016-08-31 13:56:05 -04:00
return activeBlock;
};
2016-08-31 13:56:05 -04:00
2016-12-30 10:19:58 -05:00
module.exports = {
deserialize: sb2import
};