mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-26 07:52:50 -05:00
1293 lines
55 KiB
JavaScript
1293 lines
55 KiB
JavaScript
/**
|
|
* @fileoverview
|
|
* Partial implementation of an SB2 JSON importer.
|
|
* Parses provided JSON and then generates all needed
|
|
* scratch-vm runtime structures.
|
|
*/
|
|
|
|
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 MathUtil = require('../util/math-util');
|
|
const specMap = require('./sb2_specmap');
|
|
const Comment = require('../engine/comment');
|
|
const Variable = require('../engine/variable');
|
|
const MonitorRecord = require('../engine/monitor-record');
|
|
const StageLayering = require('../engine/stage-layering');
|
|
|
|
const {loadCostume} = require('../import/load-costume.js');
|
|
const {loadSound} = require('../import/load-sound.js');
|
|
const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js');
|
|
|
|
// Constants used during deserialization of an SB2 file
|
|
const CORE_EXTENSIONS = [
|
|
'argument',
|
|
'control',
|
|
'data',
|
|
'event',
|
|
'looks',
|
|
'math',
|
|
'motion',
|
|
'operator',
|
|
'procedures',
|
|
'sensing',
|
|
'sound'
|
|
];
|
|
|
|
// Adjust script coordinates to account for
|
|
// larger block size in scratch-blocks.
|
|
// @todo: Determine more precisely the right formulas here.
|
|
const WORKSPACE_X_SCALE = 1.5;
|
|
const WORKSPACE_Y_SCALE = 2.2;
|
|
|
|
/**
|
|
* 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';
|
|
} else if (argType === 'b') {
|
|
arg.inputOp = 'boolean';
|
|
}
|
|
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
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @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.
|
|
* @param {ParseState} parseState - info on the state of parsing beyond the current block.
|
|
* @param {object<int, Comment>} comments - Comments from sb2 project that need to be attached to blocks.
|
|
* They are indexed in this object by the sb2 flattened block list index indicating
|
|
* which block they should attach to.
|
|
* @param {int} commentIndex The current index of the top block in this list if it were in a flattened
|
|
* list of all blocks for the target
|
|
* @return {Array<Array.<object>|int>} Tuple where first item is the Scratch VM-format block list, and
|
|
* second item is the updated comment index
|
|
*/
|
|
const parseBlockList = function (blockList, addBroadcastMsg, getVariableId, extensions, parseState, comments,
|
|
commentIndex) {
|
|
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 parsedBlockAndComments = parseBlock(block, addBroadcastMsg, getVariableId,
|
|
extensions, parseState, comments, commentIndex);
|
|
const parsedBlock = parsedBlockAndComments[0];
|
|
// Update commentIndex
|
|
commentIndex = parsedBlockAndComments[1];
|
|
|
|
if (!parsedBlock) continue;
|
|
if (previousBlock) {
|
|
parsedBlock.parent = previousBlock.id;
|
|
previousBlock.next = parsedBlock.id;
|
|
}
|
|
previousBlock = parsedBlock;
|
|
resultingList.push(parsedBlock);
|
|
}
|
|
return [resultingList, commentIndex];
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @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.
|
|
* @param {object} comments Comments that need to be attached to the blocks that need to be parsed
|
|
*/
|
|
const parseScripts = function (scripts, blocks, addBroadcastMsg, getVariableId, extensions, comments) {
|
|
// Keep track of the index of the current script being
|
|
// parsed in order to attach block comments correctly
|
|
let scriptIndexForComment = 0;
|
|
|
|
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 parseState = {};
|
|
const [parsedBlockList, newCommentIndex] = parseBlockList(blockList, addBroadcastMsg, getVariableId, extensions,
|
|
parseState, comments, scriptIndexForComment);
|
|
scriptIndexForComment = newCommentIndex;
|
|
if (parsedBlockList[0]) {
|
|
parsedBlockList[0].x = scriptX * WORKSPACE_X_SCALE;
|
|
parsedBlockList[0].y = scriptY * WORKSPACE_Y_SCALE;
|
|
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]);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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, type) => `${targetId}-${StringUtil.replaceUnsafeChars(name)}-${type}`;
|
|
return function (targetId, topLevel) {
|
|
// Reset the global variable map if topLevel
|
|
if (topLevel) globalVariableNameMap = {};
|
|
return function (name, type) {
|
|
if (topLevel) { // Store the name/id pair in the globalVariableNameMap
|
|
globalVariableNameMap[`${name}-${type}`] = namer(targetId, name, type);
|
|
return globalVariableNameMap[`${name}-${type}`];
|
|
}
|
|
// Not top-level, so first check the global name map
|
|
if (globalVariableNameMap[`${name}-${type}`]) return globalVariableNameMap[`${name}-${type}`];
|
|
return namer(targetId, name, type);
|
|
};
|
|
};
|
|
}());
|
|
|
|
const globalBroadcastMsgStateGenerator = (function () {
|
|
let broadcastMsgNameMap = {};
|
|
const allBroadcastFields = [];
|
|
const emptyStringName = uid();
|
|
return function (topLevel) {
|
|
if (topLevel) broadcastMsgNameMap = {};
|
|
return {
|
|
broadcastMsgMapUpdater: function (name, field) {
|
|
name = name.toLowerCase();
|
|
if (name === '') {
|
|
name = emptyStringName;
|
|
}
|
|
broadcastMsgNameMap[name] = `broadcastMsgId-${StringUtil.replaceUnsafeChars(name)}`;
|
|
allBroadcastFields.push(field);
|
|
return broadcastMsgNameMap[name];
|
|
},
|
|
globalBroadcastMsgs: broadcastMsgNameMap,
|
|
allBroadcastFields: allBroadcastFields,
|
|
emptyMsgName: emptyStringName
|
|
};
|
|
};
|
|
}());
|
|
|
|
/**
|
|
* Parse a single monitor object and create all its in-memory VM objects.
|
|
*
|
|
* It is important that monitors are parsed last,
|
|
* - after all sprite targets have finished parsing, and
|
|
* - after the rest of the stage has finished parsing.
|
|
*
|
|
* It is specifically important that all the scripts in the project
|
|
* have been parsed and all the relevant targets exist, have uids,
|
|
* and have their variables initialized.
|
|
* Calling this function before these things are true, will result in
|
|
* undefined behavior.
|
|
* @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) => {
|
|
// If we can't find the block in the spec map, ignore it.
|
|
// This happens for things like Lego Wedo 1.0 monitors.
|
|
const mapped = specMap[object.cmd];
|
|
if (!mapped) {
|
|
log.warn(`Could not find monitor block with opcode: ${object.cmd}`);
|
|
return;
|
|
}
|
|
// In scratch 2.0, there are two monitors that now correspond to extension
|
|
// blocks (tempo and video motion/direction). In the case of the
|
|
// video motion/direction block, this reporter is not monitorable in Scratch 3.0.
|
|
// In the case of the tempo block, we should import it and load the music extension
|
|
// only when the monitor is actually visible.
|
|
|
|
const opcode = specMap[object.cmd].opcode;
|
|
const extIndex = opcode.indexOf('_');
|
|
const extID = opcode.substring(0, extIndex);
|
|
|
|
if (extID === 'videoSensing') {
|
|
return;
|
|
} else if (CORE_EXTENSIONS.indexOf(extID) === -1 && extID !== '' &&
|
|
!extensions.extensionIDs.has(extID) && !object.visible) {
|
|
// Don't import this monitor if it refers to a non-core extension that
|
|
// doesn't exist anywhere else in the project and it isn't visible.
|
|
// This should only apply to the tempo block at this point since
|
|
// there are no other sb2 blocks that are now extension monitors.
|
|
return;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
{},
|
|
null, // `comments`, not needed for monitor blocks
|
|
null // `commentIndex`, not needed for monitor blocks
|
|
);
|
|
|
|
// 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:') {
|
|
block.id = getVariableId(object.param, Variable.SCALAR_TYPE);
|
|
} else if (object.cmd === 'contentsOfList:') {
|
|
block.id = getVariableId(object.param, Variable.LIST_TYPE);
|
|
} else if (runtime.monitorBlockInfo.hasOwnProperty(block.opcode)) {
|
|
block.id = runtime.monitorBlockInfo[block.opcode].getId(target.id, block.fields);
|
|
} else {
|
|
// If the opcode can't be found in the runtime monitorBlockInfo,
|
|
// then default to using the block opcode as the id instead.
|
|
// This is for extension monitors, and assumes that extension monitors
|
|
// cannot be sprite specific.
|
|
block.id = block.opcode;
|
|
}
|
|
|
|
// 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;
|
|
|
|
const existingMonitorBlock = runtime.monitorBlocks._blocks[block.id];
|
|
if (existingMonitorBlock) {
|
|
// A monitor block already exists if the toolbox has been loaded and
|
|
// the monitor block is not target specific (because the block gets recycled).
|
|
// Update the existing block with the relevant monitor information.
|
|
existingMonitorBlock.isMonitored = object.visible;
|
|
existingMonitorBlock.targetId = block.targetId;
|
|
} else {
|
|
// 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,
|
|
isDiscrete: object.isDiscrete,
|
|
x: object.x,
|
|
y: object.y,
|
|
width: object.width,
|
|
height: object.height,
|
|
visible: object.visible
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Parse the assets of a single "Scratch object" and load them. This
|
|
* preprocesses objects to support loading the data for those assets over a
|
|
* network while the objects are further processed into Blocks, Sprites, and a
|
|
* list of needed Extensions.
|
|
* @param {!object} object - From-JSON "Scratch object:" sprite, stage, watcher.
|
|
* @param {!Runtime} runtime - Runtime object to load all structures into.
|
|
* @param {boolean} topLevel - Whether this is the top-level object (stage).
|
|
* @param {?object} zip - Optional zipped assets for local file import
|
|
* @return {?{costumePromises:Array.<Promise>,soundPromises:Array.<Promise>,soundBank:SoundBank,children:object}}
|
|
* Object of arrays of promises and child objects for asset objects used in
|
|
* Sprites. As well as a SoundBank for the sound assets. null for unsupported
|
|
* objects.
|
|
*/
|
|
const parseScratchAssets = function (object, runtime, topLevel, zip) {
|
|
if (!object.hasOwnProperty('objName')) {
|
|
// Skip parsing monitors. Or any other objects missing objName.
|
|
return null;
|
|
}
|
|
|
|
const assets = {
|
|
costumePromises: [],
|
|
soundPromises: [],
|
|
soundBank: runtime.audioEngine && runtime.audioEngine.createBank(),
|
|
children: []
|
|
};
|
|
|
|
// Costumes from JSON.
|
|
const costumePromises = assets.costumePromises;
|
|
if (object.hasOwnProperty('costumes')) {
|
|
for (let i = 0; i < object.costumes.length; i++) {
|
|
const costumeSource = object.costumes[i];
|
|
const bitmapResolution = costumeSource.bitmapResolution || 1;
|
|
const costume = {
|
|
name: costumeSource.costumeName,
|
|
bitmapResolution: bitmapResolution,
|
|
rotationCenterX: topLevel ? 240 * bitmapResolution : costumeSource.rotationCenterX,
|
|
rotationCenterY: topLevel ? 180 * bitmapResolution : 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];
|
|
let ext;
|
|
if (idParts.length === 2 && idParts[1]) {
|
|
ext = idParts[1];
|
|
} else {
|
|
// Default to 'png' if baseLayerMD5 is not formatted correctly
|
|
ext = 'png';
|
|
// Fix costume md5 for later
|
|
costume.md5 = `${costume.md5}.${ext}`;
|
|
}
|
|
costume.dataFormat = ext;
|
|
costume.assetId = md5;
|
|
if (costumeSource.textLayerMD5) {
|
|
costume.textLayerMD5 = StringUtil.splitFirst(costumeSource.textLayerMD5, '.')[0];
|
|
}
|
|
// 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}`;
|
|
const textLayerFileName = costumeSource.textLayerID ? `${costumeSource.textLayerID}.png` : null;
|
|
costumePromises.push(deserializeCostume(costume, runtime, zip, assetFileName, textLayerFileName)
|
|
.then(() => loadCostume(costume.md5, costume, runtime, 2 /* optVersion */))
|
|
);
|
|
}
|
|
}
|
|
// Sounds from JSON
|
|
const {soundBank, soundPromises} = assets;
|
|
if (object.hasOwnProperty('sounds')) {
|
|
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, soundBank))
|
|
);
|
|
}
|
|
}
|
|
|
|
// The stage will have child objects; recursively process them.
|
|
const childrenAssets = assets.children;
|
|
if (object.children) {
|
|
for (let m = 0; m < object.children.length; m++) {
|
|
childrenAssets.push(parseScratchAssets(object.children[m], runtime, false, zip));
|
|
}
|
|
}
|
|
|
|
return assets;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param {object} assets - Promises for assets of this scratch object grouped
|
|
* into costumes and sounds
|
|
* @return {!Promise.<Array.<Target>>} Promise for the loaded targets when ready, or null for unsupported objects.
|
|
*/
|
|
const parseScratchObject = function (object, runtime, extensions, topLevel, zip, assets) {
|
|
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);
|
|
}
|
|
|
|
// Blocks container for this object.
|
|
const blocks = new Blocks(runtime);
|
|
// @todo: For now, load all Scratch objects (stage/sprites) as a Sprite.
|
|
const sprite = new Sprite(blocks, runtime);
|
|
// Sprite/stage name from JSON.
|
|
if (object.hasOwnProperty('objName')) {
|
|
if (topLevel && object.objName !== 'Stage') {
|
|
for (const child of object.children) {
|
|
if (!child.hasOwnProperty('objName') && child.target === object.objName) {
|
|
child.target = 'Stage';
|
|
}
|
|
}
|
|
object.objName = 'Stage';
|
|
}
|
|
|
|
sprite.name = object.objName;
|
|
}
|
|
// Costumes from JSON.
|
|
const costumePromises = assets.costumePromises;
|
|
// Sounds from JSON
|
|
const {soundBank, soundPromises} = assets;
|
|
|
|
// Create the first clone, and load its run-state from JSON.
|
|
const target = sprite.createClone(topLevel ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER);
|
|
|
|
const getVariableId = generateVariableIdGetter(target.id, topLevel);
|
|
|
|
const globalBroadcastMsgObj = globalBroadcastMsgStateGenerator(topLevel);
|
|
const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater;
|
|
|
|
// Load target properties from JSON.
|
|
if (object.hasOwnProperty('variables')) {
|
|
for (let j = 0; j < object.variables.length; j++) {
|
|
const variable = object.variables[j];
|
|
// A variable is a cloud variable if:
|
|
// - the project says it's a cloud variable, and
|
|
// - it's a stage variable, and
|
|
// - the runtime can support another cloud variable
|
|
const isCloud = variable.isPersistent && topLevel && runtime.canAddCloudVariable();
|
|
const newVariable = new Variable(
|
|
getVariableId(variable.name, Variable.SCALAR_TYPE),
|
|
variable.name,
|
|
Variable.SCALAR_TYPE,
|
|
isCloud
|
|
);
|
|
if (isCloud) runtime.addCloudVariable();
|
|
newVariable.value = variable.value;
|
|
target.variables[newVariable.id] = newVariable;
|
|
}
|
|
}
|
|
|
|
// If included, parse any and all comments on the object (this includes top-level
|
|
// workspace comments as well as comments attached to specific blocks)
|
|
const blockComments = {};
|
|
if (object.hasOwnProperty('scriptComments')) {
|
|
const comments = object.scriptComments.map(commentDesc => {
|
|
const [
|
|
commentX,
|
|
commentY,
|
|
commentWidth,
|
|
commentHeight,
|
|
commentFullSize,
|
|
flattenedBlockIndex,
|
|
commentText
|
|
] = commentDesc;
|
|
const isBlockComment = commentDesc[5] >= 0;
|
|
const newComment = new Comment(
|
|
null, // generate a new id for this comment
|
|
commentText, // text content of sb2 comment
|
|
// Only serialize x & y position of comment if it's a workspace comment
|
|
// If it's a block comment, we'll let scratch-blocks handle positioning
|
|
isBlockComment ? null : commentX * WORKSPACE_X_SCALE,
|
|
isBlockComment ? null : commentY * WORKSPACE_Y_SCALE,
|
|
commentWidth * WORKSPACE_X_SCALE,
|
|
commentHeight * WORKSPACE_Y_SCALE,
|
|
!commentFullSize
|
|
);
|
|
if (isBlockComment) {
|
|
// commentDesc[5] refers to the index of the block that this
|
|
// comment is attached to -- in a flattened version of the
|
|
// scripts array.
|
|
// If commentDesc[5] is -1, this is a workspace comment (we don't need to do anything
|
|
// extra at this point), otherwise temporarily save the flattened script array
|
|
// index as the blockId property of the new comment. We will
|
|
// change this to refer to the actual block id of the corresponding
|
|
// block when that block gets created
|
|
newComment.blockId = flattenedBlockIndex;
|
|
// Add this comment to the block comments object with its script index
|
|
// as the key
|
|
if (blockComments.hasOwnProperty(flattenedBlockIndex)) {
|
|
blockComments[flattenedBlockIndex].push(newComment);
|
|
} else {
|
|
blockComments[flattenedBlockIndex] = [newComment];
|
|
}
|
|
}
|
|
return newComment;
|
|
});
|
|
|
|
// Add all the comments that were just created to the target.comments,
|
|
// referenced by id
|
|
comments.forEach(comment => {
|
|
target.comments[comment.id] = comment;
|
|
});
|
|
}
|
|
|
|
// If included, parse any and all scripts/blocks on the object.
|
|
if (object.hasOwnProperty('scripts')) {
|
|
parseScripts(object.scripts, blocks, addBroadcastMsg, getVariableId, extensions, blockComments);
|
|
}
|
|
|
|
// If there are any comments referring to a numerical block ID, make them
|
|
// workspace comments. These are comments that were originally created as
|
|
// block comments, detached from the block, and then had the associated
|
|
// block deleted.
|
|
// These comments should be imported as workspace comments
|
|
// by making their blockIDs (which currently refer to non-existing blocks)
|
|
// null (See #1452).
|
|
for (const commentIndex in blockComments) {
|
|
const currBlockComments = blockComments[commentIndex];
|
|
currBlockComments.forEach(c => {
|
|
if (typeof c.blockId === 'number') {
|
|
c.blockId = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update stage specific blocks (e.g. sprite clicked <=> stage clicked)
|
|
blocks.updateTargetSpecificBlocks(topLevel); // topLevel = isStage
|
|
|
|
if (object.hasOwnProperty('lists')) {
|
|
for (let k = 0; k < object.lists.length; k++) {
|
|
const list = object.lists[k];
|
|
const newVariable = new Variable(
|
|
getVariableId(list.listName, Variable.LIST_TYPE),
|
|
list.listName,
|
|
Variable.LIST_TYPE,
|
|
false
|
|
);
|
|
newVariable.value = list.contents;
|
|
target.variables[newVariable.id] = newVariable;
|
|
}
|
|
}
|
|
if (object.hasOwnProperty('scratchX')) {
|
|
target.x = object.scratchX;
|
|
}
|
|
if (object.hasOwnProperty('scratchY')) {
|
|
target.y = object.scratchY;
|
|
}
|
|
if (object.hasOwnProperty('direction')) {
|
|
target.direction = object.direction;
|
|
}
|
|
if (object.hasOwnProperty('isDraggable')) {
|
|
target.draggable = object.isDraggable;
|
|
}
|
|
if (object.hasOwnProperty('scale')) {
|
|
// SB2 stores as 1.0 = 100%; we use % in the VM.
|
|
target.size = object.scale * 100;
|
|
}
|
|
if (object.hasOwnProperty('visible')) {
|
|
target.visible = object.visible;
|
|
}
|
|
if (object.hasOwnProperty('currentCostumeIndex')) {
|
|
// Current costume index can sometimes be a floating
|
|
// point number, use Math.floor to come up with an appropriate index
|
|
// and clamp it to the actual number of costumes the object has for good measure.
|
|
target.currentCostume = MathUtil.clamp(Math.floor(object.currentCostumeIndex), 0, object.costumes.length - 1);
|
|
}
|
|
if (object.hasOwnProperty('rotationStyle')) {
|
|
if (object.rotationStyle === 'none') {
|
|
target.rotationStyle = RenderedTarget.ROTATION_STYLE_NONE;
|
|
} else if (object.rotationStyle === 'leftRight') {
|
|
target.rotationStyle = RenderedTarget.ROTATION_STYLE_LEFT_RIGHT;
|
|
} else if (object.rotationStyle === 'normal') {
|
|
target.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND;
|
|
}
|
|
}
|
|
if (object.hasOwnProperty('tempoBPM')) {
|
|
target.tempo = object.tempoBPM;
|
|
}
|
|
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;
|
|
} else {
|
|
target.videoState = RenderedTarget.VIDEO_STATE.OFF;
|
|
}
|
|
}
|
|
}
|
|
if (object.hasOwnProperty('indexInLibrary')) {
|
|
// Temporarily store the 'indexInLibrary' property from the sb2 file
|
|
// so that we can correctly order sprites in the target pane.
|
|
// This will be deleted after we are done parsing and ordering the targets list.
|
|
target.targetPaneOrder = object.indexInLibrary;
|
|
}
|
|
|
|
target.isStage = topLevel;
|
|
|
|
Promise.all(costumePromises).then(costumes => {
|
|
sprite.costumes = costumes;
|
|
});
|
|
|
|
Promise.all(soundPromises).then(sounds => {
|
|
sprite.sounds = sounds;
|
|
// Make sure if soundBank is undefined, sprite.soundBank is then null.
|
|
sprite.soundBank = soundBank || null;
|
|
});
|
|
|
|
// The stage will have child objects; recursively process them.
|
|
const childrenPromises = [];
|
|
if (object.children) {
|
|
for (let m = 0; m < object.children.length; m++) {
|
|
childrenPromises.push(
|
|
parseScratchObject(object.children[m], runtime, extensions, false, zip, assets.children[m])
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
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}`;
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
// 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;
|
|
}
|
|
}
|
|
let targets = [target];
|
|
const deferredMonitors = [];
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
// It is important that monitors are parsed last
|
|
// - after all sprite targets have finished parsing
|
|
// - and this is the last thing that happens in the stage parsing
|
|
// It is specifically important that all the scripts in the project
|
|
// have been parsed and all the relevant targets exist, have uids,
|
|
// and have their variables initialized.
|
|
for (let n = 0; n < deferredMonitors.length; n++) {
|
|
parseMonitorObject(deferredMonitors[n], runtime, targets, extensions);
|
|
}
|
|
return targets;
|
|
})
|
|
);
|
|
};
|
|
|
|
const reorderParsedTargets = function (targets) {
|
|
// Reorder parsed targets based on the temporary targetPaneOrder property
|
|
// and then delete it.
|
|
|
|
const reordered = targets.map((t, index) => {
|
|
t.layerOrder = index;
|
|
return t;
|
|
}).sort((a, b) => a.targetPaneOrder - b.targetPaneOrder);
|
|
|
|
// Delete the temporary target pane ordering since we shouldn't need it anymore.
|
|
reordered.forEach(t => {
|
|
delete t.targetPaneOrder;
|
|
});
|
|
|
|
return reordered;
|
|
};
|
|
|
|
|
|
/**
|
|
* Top-level handler. Parse provided JSON,
|
|
* and process the top-level object (the stage object).
|
|
* @param {!object} json SB2-format JSON to load.
|
|
* @param {!Runtime} runtime Runtime object to load all structures into.
|
|
* @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 Promise.resolve(parseScratchAssets(json, runtime, !optForceSprite, zip))
|
|
// Force this promise to wait for the next loop in the js tick. Let
|
|
// storage have some time to send off asset requests.
|
|
.then(assets => Promise.resolve(assets))
|
|
.then(assets => (
|
|
parseScratchObject(json, runtime, extensions, !optForceSprite, zip, assets)
|
|
))
|
|
.then(reorderParsedTargets)
|
|
.then(targets => ({
|
|
targets,
|
|
extensions
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
};
|
|
|
|
/**
|
|
* Parse a single SB2 JSON-formatted block and its children.
|
|
* @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.
|
|
* @param {ParseState} parseState - info on the state of parsing beyond the current block.
|
|
* @param {object<int, Comment>} comments - Comments from sb2 project that need to be attached to blocks.
|
|
* They are indexed in this object by the sb2 flattened block list index indicating
|
|
* which block they should attach to.
|
|
* @param {int} commentIndex The comment index for the block to be parsed if it were in a flattened
|
|
* list of all blocks for the target
|
|
* @return {Array.<object|int>} Tuple where first item is the Scratch VM-format block (or null if unsupported object),
|
|
* and second item is the updated comment index (after this block and its children are parsed)
|
|
*/
|
|
const parseBlock = function (sb2block, addBroadcastMsg, getVariableId, extensions, parseState, comments, commentIndex) {
|
|
const commentsForParsedBlock = (comments && typeof commentIndex === 'number' && !isNaN(commentIndex)) ?
|
|
comments[commentIndex] : null;
|
|
const blockMetadata = specMapBlock(sb2block);
|
|
if (!blockMetadata) {
|
|
// No block opcode found, exclude this block, increment the commentIndex,
|
|
// make all block comments into workspace comments and send them to zero/zero
|
|
// to prevent serialization issues.
|
|
if (commentsForParsedBlock) {
|
|
commentsForParsedBlock.forEach(comment => {
|
|
comment.blockId = null;
|
|
comment.x = comment.y = 0;
|
|
});
|
|
}
|
|
return [null, commentIndex + 1];
|
|
}
|
|
const oldOpcode = sb2block[0];
|
|
|
|
// If the block is from an extension, record it.
|
|
const index = blockMetadata.opcode.indexOf('_');
|
|
const prefix = blockMetadata.opcode.substring(0, index);
|
|
if (CORE_EXTENSIONS.indexOf(prefix) === -1) {
|
|
if (prefix !== '') extensions.extensionIDs.add(prefix);
|
|
}
|
|
|
|
// Block skeleton.
|
|
const activeBlock = {
|
|
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.
|
|
shadow: false, // No shadow blocks in an SB2 by default.
|
|
children: [] // Store any generated children, flattened in `flatten`.
|
|
};
|
|
|
|
// Attach any comments to this block..
|
|
if (commentsForParsedBlock) {
|
|
// Attach only the last comment to the block, make all others workspace comments
|
|
activeBlock.comment = commentsForParsedBlock[commentsForParsedBlock.length - 1].id;
|
|
commentsForParsedBlock.forEach(comment => {
|
|
if (comment.id === activeBlock.comment) {
|
|
comment.blockId = activeBlock.id;
|
|
} else {
|
|
// All other comments don't get a block ID and are sent back to zero.
|
|
// This is important, because if they have `null` x/y, serialization breaks.
|
|
comment.blockId = null;
|
|
comment.x = comment.y = 0;
|
|
}
|
|
});
|
|
}
|
|
commentIndex++;
|
|
|
|
const parentExpectedArg = parseState.expectedArg;
|
|
|
|
// For a procedure call, generate argument map from proc string.
|
|
if (oldOpcode === 'call') {
|
|
blockMetadata.argMap = parseProcedureArgMap(sb2block[1]);
|
|
}
|
|
// 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.
|
|
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.
|
|
let shadowObscured = false;
|
|
// Positional argument is an input.
|
|
if (expectedArg.type === 'input') {
|
|
// Create a new block and input metadata.
|
|
const inputUid = uid();
|
|
activeBlock.inputs[expectedArg.inputName] = {
|
|
name: expectedArg.inputName,
|
|
block: null,
|
|
shadow: null
|
|
};
|
|
if (typeof providedArg === 'object' && providedArg) {
|
|
// Block or block list occupies the input.
|
|
let innerBlocks;
|
|
parseState.expectedArg = expectedArg;
|
|
if (typeof providedArg[0] === 'object' && providedArg[0]) {
|
|
// Block list occupies the input.
|
|
[innerBlocks, commentIndex] = parseBlockList(providedArg, addBroadcastMsg, getVariableId,
|
|
extensions, parseState, comments, commentIndex);
|
|
} else {
|
|
// Single block occupies the input.
|
|
const parsedBlockDesc = parseBlock(providedArg, addBroadcastMsg, getVariableId, extensions,
|
|
parseState, comments, commentIndex);
|
|
innerBlocks = parsedBlockDesc[0] ? [parsedBlockDesc[0]] : [];
|
|
// Update commentIndex
|
|
commentIndex = parsedBlockDesc[1];
|
|
}
|
|
parseState.expectedArg = parentExpectedArg;
|
|
|
|
// Check if innerBlocks is not an empty list.
|
|
// An empty list indicates that all the inner blocks from the sb2 have
|
|
// unknown opcodes and have been skipped.
|
|
if (innerBlocks.length > 0) {
|
|
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;
|
|
}
|
|
activeBlock.inputs[expectedArg.inputName].block = (
|
|
innerBlocks[0].id
|
|
);
|
|
activeBlock.children = (
|
|
activeBlock.children.concat(innerBlocks)
|
|
);
|
|
}
|
|
|
|
// Obscures any shadow.
|
|
shadowObscured = true;
|
|
}
|
|
// Generate a shadow block to occupy the input.
|
|
if (!expectedArg.inputOp) {
|
|
// Undefined inputOp. inputOp should always be defined for inputs.
|
|
log.warn(`Unknown input operation for input ${expectedArg.inputName} of opcode ${activeBlock.opcode}.`);
|
|
continue;
|
|
}
|
|
if (expectedArg.inputOp === 'boolean' || expectedArg.inputOp === 'substack') {
|
|
// 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.
|
|
let fieldValue = providedArg;
|
|
// Shadows' field names match the input name, except for these:
|
|
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 = '';
|
|
}
|
|
} 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 === 'sensing_of_object_menu') {
|
|
if (shadowObscured) {
|
|
fieldValue = '_stage_';
|
|
} else if (fieldValue === 'Stage') {
|
|
fieldValue = '_stage_';
|
|
}
|
|
} else if (expectedArg.inputOp === 'note') {
|
|
if (shadowObscured) {
|
|
fieldValue = 60;
|
|
}
|
|
} 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 = '';
|
|
}
|
|
const fields = {};
|
|
fields[fieldName] = {
|
|
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;
|
|
}
|
|
} else if (expectedArg.type === 'field') {
|
|
// Add as a field on this block.
|
|
activeBlock.fields[expectedArg.fieldName] = {
|
|
name: expectedArg.fieldName,
|
|
value: providedArg
|
|
};
|
|
|
|
if (expectedArg.fieldName === 'CURRENTMENU') {
|
|
// In 3.0, the field value of the `sensing_current` block
|
|
// is in all caps.
|
|
activeBlock.fields[expectedArg.fieldName].value = providedArg.toUpperCase();
|
|
if (providedArg === 'day of week') {
|
|
activeBlock.fields[expectedArg.fieldName].value = 'DAYOFWEEK';
|
|
}
|
|
}
|
|
|
|
if (expectedArg.fieldName === 'VARIABLE') {
|
|
// Add `id` property to variable fields
|
|
activeBlock.fields[expectedArg.fieldName].id = getVariableId(providedArg, Variable.SCALAR_TYPE);
|
|
} else if (expectedArg.fieldName === 'LIST') {
|
|
// Add `id` property to variable fields
|
|
activeBlock.fields[expectedArg.fieldName].id = getVariableId(providedArg, Variable.LIST_TYPE);
|
|
} else if (expectedArg.fieldName === 'BROADCAST_OPTION') {
|
|
// 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;
|
|
}
|
|
const varType = expectedArg.variableType;
|
|
if (typeof varType === 'string') {
|
|
activeBlock.fields[expectedArg.fieldName].variableType = varType;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
case 'costumeName':
|
|
activeBlock.fields.NUMBER_NAME = {
|
|
name: 'NUMBER_NAME',
|
|
value: 'name'
|
|
};
|
|
break;
|
|
}
|
|
|
|
// Special cases to generate mutations.
|
|
if (oldOpcode === 'stopScripts') {
|
|
// 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') {
|
|
activeBlock.mutation = {
|
|
tagName: 'mutation',
|
|
hasnext: 'true',
|
|
children: []
|
|
};
|
|
}
|
|
} else if (oldOpcode === 'procDef') {
|
|
// Mutation for procedure definition:
|
|
// store all 2.0 proc data.
|
|
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
|
|
};
|
|
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') {
|
|
// 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]))
|
|
};
|
|
} else if (oldOpcode === 'getParam') {
|
|
let returnCode = sb2block[2];
|
|
|
|
// Ensure the returnCode is "b" if used in a boolean input.
|
|
if (parentExpectedArg && parentExpectedArg.inputOp === 'boolean' && returnCode !== 'b') {
|
|
returnCode = 'b';
|
|
}
|
|
|
|
// Assign correct opcode based on the block shape.
|
|
switch (returnCode) {
|
|
case 'r':
|
|
activeBlock.opcode = 'argument_reporter_string_number';
|
|
break;
|
|
case 'b':
|
|
activeBlock.opcode = 'argument_reporter_boolean';
|
|
break;
|
|
}
|
|
}
|
|
return [activeBlock, commentIndex];
|
|
};
|
|
|
|
module.exports = {
|
|
deserialize: sb2import
|
|
};
|