/** * @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.} 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.} blocks list generated by `parseBlockList`. * @return {Array.} 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.} 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} 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|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.} 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.,soundPromises:Array.,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.>} 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.} 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} 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.} 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 };