const TextEncoder = require('text-encoding').TextEncoder; const EventEmitter = require('events'); const JSZip = require('jszip'); const centralDispatch = require('./dispatch/central-dispatch'); const ExtensionManager = require('./extension-support/extension-manager'); const log = require('./util/log'); const Runtime = require('./engine/runtime'); const sb2 = require('./serialization/sb2'); const sb3 = require('./serialization/sb3'); const StringUtil = require('./util/string-util'); const formatMessage = require('format-message'); const validate = require('scratch-parser'); const Variable = require('./engine/variable'); const {loadCostume} = require('./import/load-costume.js'); const {loadSound} = require('./import/load-sound.js'); const {serializeSounds, serializeCostumes} = require('./serialization/serialize-assets'); const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; const CORE_EXTENSIONS = [ // 'motion', // 'looks', // 'sound', // 'events', // 'control', // 'sensing', // 'operators', // 'variables', // 'myBlocks' ]; /** * Handles connections between blocks, stage, and extensions. * @constructor */ class VirtualMachine extends EventEmitter { constructor () { super(); /** * VM runtime, to store blocks, I/O devices, sprites/targets, etc. * @type {!Runtime} */ this.runtime = new Runtime(); centralDispatch.setService('runtime', this.runtime).catch(e => { log.error(`Failed to register runtime service: ${JSON.stringify(e)}`); }); /** * The "currently editing"/selected target ID for the VM. * Block events from any Blockly workspace are routed to this target. * @type {Target} */ this.editingTarget = null; /** * The currently dragging target, for redirecting IO data. * @type {Target} */ this._dragTarget = null; // Runtime emits are passed along as VM emits. this.runtime.on(Runtime.SCRIPT_GLOW_ON, glowData => { this.emit(Runtime.SCRIPT_GLOW_ON, glowData); }); this.runtime.on(Runtime.SCRIPT_GLOW_OFF, glowData => { this.emit(Runtime.SCRIPT_GLOW_OFF, glowData); }); this.runtime.on(Runtime.BLOCK_GLOW_ON, glowData => { this.emit(Runtime.BLOCK_GLOW_ON, glowData); }); this.runtime.on(Runtime.BLOCK_GLOW_OFF, glowData => { this.emit(Runtime.BLOCK_GLOW_OFF, glowData); }); this.runtime.on(Runtime.PROJECT_RUN_START, () => { this.emit(Runtime.PROJECT_RUN_START); }); this.runtime.on(Runtime.PROJECT_RUN_STOP, () => { this.emit(Runtime.PROJECT_RUN_STOP); }); this.runtime.on(Runtime.VISUAL_REPORT, visualReport => { this.emit(Runtime.VISUAL_REPORT, visualReport); }); this.runtime.on(Runtime.TARGETS_UPDATE, () => { this.emitTargetsUpdate(); }); this.runtime.on(Runtime.MONITORS_UPDATE, monitorList => { this.emit(Runtime.MONITORS_UPDATE, monitorList); }); this.runtime.on(Runtime.BLOCK_DRAG_UPDATE, areBlocksOverGui => { this.emit(Runtime.BLOCK_DRAG_UPDATE, areBlocksOverGui); }); this.runtime.on(Runtime.BLOCK_DRAG_END, blocks => { this.emit(Runtime.BLOCK_DRAG_END, blocks); }); this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => { this.emit(Runtime.EXTENSION_ADDED, blocksInfo); }); this.runtime.on(Runtime.BLOCKSINFO_UPDATE, blocksInfo => { this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo); }); this.extensionManager = new ExtensionManager(this.runtime); this.blockListener = this.blockListener.bind(this); this.flyoutBlockListener = this.flyoutBlockListener.bind(this); this.monitorBlockListener = this.monitorBlockListener.bind(this); this.variableListener = this.variableListener.bind(this); } /** * Start running the VM - do this before anything else. */ start () { this.runtime.start(); } /** * "Green flag" handler - start all threads starting with a green flag. */ greenFlag () { this.runtime.greenFlag(); } /** * Set whether the VM is in "turbo mode." * When true, loops don't yield to redraw. * @param {boolean} turboModeOn Whether turbo mode should be set. */ setTurboMode (turboModeOn) { this.runtime.turboMode = !!turboModeOn; } /** * Set whether the VM is in 2.0 "compatibility mode." * When true, ticks go at 2.0 speed (30 TPS). * @param {boolean} compatibilityModeOn Whether compatibility mode is set. */ setCompatibilityMode (compatibilityModeOn) { this.runtime.setCompatibilityMode(!!compatibilityModeOn); } /** * Stop all threads and running activities. */ stopAll () { this.runtime.stopAll(); } /** * Clear out current running project data. */ clear () { this.runtime.dispose(); this.editingTarget = null; this.emitTargetsUpdate(); } /** * Get data for playground. Data comes back in an emitted event. */ getPlaygroundData () { const instance = this; // Only send back thread data for the current editingTarget. const threadData = this.runtime.threads.filter(thread => thread.target === instance.editingTarget); // Remove the target key, since it's a circular reference. const filteredThreadData = JSON.stringify(threadData, (key, value) => { if (key === 'target') return; return value; }, 2); this.emit('playgroundData', { blocks: this.editingTarget.blocks, threads: filteredThreadData }); } /** * Post I/O data to the virtual devices. * @param {?string} device Name of virtual I/O device. * @param {object} data Any data object to post to the I/O device. */ postIOData (device, data) { if (this.runtime.ioDevices[device]) { this.runtime.ioDevices[device].postData(data); } } /** * Load a Scratch project from a .sb, .sb2, .sb3 or json string. * @param {string | object} input A json string, object, or ArrayBuffer representing the project to load. * @return {!Promise} Promise that resolves after targets are installed. */ loadProject (input) { if (typeof input === 'object' && !(input instanceof ArrayBuffer) && !ArrayBuffer.isView(input)) { // If the input is an object and not any ArrayBuffer // or an ArrayBuffer view (this includes all typed arrays and DataViews) // turn the object into a JSON string, because we suspect // this is a project.json as an object // validate expects a string or buffer as input // TODO not sure if we need to check that it also isn't a data view input = JSON.stringify(input); } const validationPromise = new Promise((resolve, reject) => { validate(input, (error, res) => { if (error) { reject(error); } if (res) { resolve(res); } }); }); return validationPromise .then(validatedInput => this.deserializeProject(validatedInput[0], validatedInput[1])) .catch(error => { // Intentionally rejecting here (want errors to be handled by caller) if (error.hasOwnProperty('validationError')) { return Promise.reject(JSON.stringify(error)); } return Promise.reject(error); }); } /** * Load a project from the Scratch web site, by ID. * @param {string} id - the ID of the project to download, as a string. */ downloadProjectId (id) { const storage = this.runtime.storage; if (!storage) { log.error('No storage module present; cannot load project: ', id); return; } const vm = this; const promise = storage.load(storage.AssetType.Project, id); promise.then(projectAsset => { vm.loadProject(projectAsset.decodeText()); }); } /** * @returns {string} Project in a Scratch 3.0 JSON representation. */ saveProjectSb3 () { const soundDescs = serializeSounds(this.runtime); const costumeDescs = serializeCostumes(this.runtime); const projectJson = this.toJSON(); // TODO want to eventually move zip creation out of here, and perhaps // into scratch-storage const zip = new JSZip(); // Put everything in a zip file zip.file('project.json', projectJson); for (let i = 0; i < soundDescs.length; i++) { const currSound = soundDescs[i]; zip.file(currSound.fileName, currSound.fileContent); } for (let i = 0; i < costumeDescs.length; i++) { const currCostume = costumeDescs[i]; zip.file(currCostume.fileName, currCostume.fileContent); } return zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 // Tradeoff between best speed (1) and best compression (9) } }); } /** * Export project as a Scratch 3.0 JSON representation. * @return {string} Serialized state of the runtime. */ toJSON () { return JSON.stringify(sb3.serialize(this.runtime)); } // TODO do we still need this function? Keeping it here so as not to introduce // a breaking change. /** * Load a project from a Scratch JSON representation. * @param {string} json JSON string representing a project. * @returns {Promise} Promise that resolves after the project has loaded */ fromJSON (json) { log.warning('fromJSON is now just a wrapper around loadProject, please use that function instead.'); return this.loadProject(json); } /** * Load a project from a Scratch JSON representation. * @param {string} projectJSON JSON string representing a project. * @param {?JSZip} zip Optional zipped project containing assets to be loaded. * @returns {Promise} Promise that resolves after the project has loaded */ deserializeProject (projectJSON, zip) { // Clear the current runtime this.clear(); const runtime = this.runtime; const deserializePromise = function () { const projectVersion = projectJSON.projectVersion; if (projectVersion === 2) { return sb2.deserialize(projectJSON, runtime, false, zip); } if (projectVersion === 3) { return sb3.deserialize(projectJSON, runtime, zip); } return Promise.reject('Unable to verify Scratch Project version.'); }; return deserializePromise() .then(({targets, extensions}) => this.installTargets(targets, extensions, true)); } /** * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets. * @param {Array.} targets - the targets to be installed * @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets * @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite. * @returns {Promise} resolved once targets have been installed */ installTargets (targets, extensions, wholeProject) { const extensionPromises = []; if (wholeProject) { this.clear(); CORE_EXTENSIONS.forEach(extensionID => { if (!this.extensionManager.isExtensionLoaded(extensionID)) { extensionPromises.push(this.extensionManager.loadExtensionURL(extensionID)); } }); } extensions.extensionIDs.forEach(extensionID => { if (!this.extensionManager.isExtensionLoaded(extensionID)) { const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL)); } }); targets = targets.filter(target => !!target); return Promise.all(extensionPromises).then(() => { targets.forEach(target => { this.runtime.targets.push(target); (/** @type RenderedTarget */ target).updateAllDrawableProperties(); // Ensure unique sprite name if (target.isSprite()) this.renameSprite(target.id, target.getName()); }); // Select the first target for editing, e.g., the first sprite. if (wholeProject && (targets.length > 1)) { this.editingTarget = targets[1]; } else { this.editingTarget = targets[0]; } // Update the VM user's knowledge of targets and blocks on the workspace. this.emitTargetsUpdate(); this.emitWorkspaceUpdate(); this.runtime.setEditingTarget(this.editingTarget); }); } /** * Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format. * @param {string} json JSON string representing the sprite. * @returns {Promise} Promise that resolves after the sprite is added */ addSprite2 (json) { // Validate & parse if (typeof json !== 'string') { log.error('Failed to parse sprite. Non-string supplied to addSprite2.'); return; } json = JSON.parse(json); if (typeof json !== 'object') { log.error('Failed to parse sprite. JSON supplied to addSprite2 is not an object.'); return; } return sb2.deserialize(json, this.runtime, true) .then(({targets, extensions}) => this.installTargets(targets, extensions, false)); } /** * Add a costume to the current editing target. * @param {string} md5ext - the MD5 and extension of the costume to be loaded. * @param {!object} costumeObject Object representing the costume. * @property {int} skinId - the ID of the costume's render skin, once installed. * @property {number} rotationCenterX - the X component of the costume's origin. * @property {number} rotationCenterY - the Y component of the costume's origin. * @property {number} [bitmapResolution] - the resolution scale for a bitmap costume. * @returns {?Promise} - a promise that resolves when the costume has been added */ addCostume (md5ext, costumeObject) { return loadCostume(md5ext, costumeObject, this.runtime).then(() => { this.editingTarget.addCostume(costumeObject); this.editingTarget.setCostume( this.editingTarget.getCostumes().length - 1 ); }); } /** * Duplicate the costume at the given index. Add it at that index + 1. * @param {!int} costumeIndex Index of costume to duplicate * @returns {?Promise} - a promise that resolves when the costume has been decoded and added */ duplicateCostume (costumeIndex) { const originalCostume = this.editingTarget.getCostumes()[costumeIndex]; const clone = Object.assign({}, originalCostume); const md5ext = `${clone.assetId}.${clone.dataFormat}`; return loadCostume(md5ext, clone, this.runtime).then(() => { this.editingTarget.addCostume(clone, costumeIndex + 1); this.editingTarget.setCostume(costumeIndex + 1); this.emitTargetsUpdate(); }); } /** * Duplicate the sound at the given index. Add it at that index + 1. * @param {!int} soundIndex Index of sound to duplicate * @returns {?Promise} - a promise that resolves when the sound has been decoded and added */ duplicateSound (soundIndex) { const originalSound = this.editingTarget.getSounds()[soundIndex]; const clone = Object.assign({}, originalSound); return loadSound(clone, this.runtime).then(() => { this.editingTarget.addSound(clone, soundIndex + 1); this.emitTargetsUpdate(); }); } /** * Rename a costume on the current editing target. * @param {int} costumeIndex - the index of the costume to be renamed. * @param {string} newName - the desired new name of the costume (will be modified if already in use). */ renameCostume (costumeIndex, newName) { this.editingTarget.renameCostume(costumeIndex, newName); this.emitTargetsUpdate(); } /** * Delete a costume from the current editing target. * @param {int} costumeIndex - the index of the costume to be removed. */ deleteCostume (costumeIndex) { this.editingTarget.deleteCostume(costumeIndex); } /** * Add a sound to the current editing target. * @param {!object} soundObject Object representing the costume. * @returns {?Promise} - a promise that resolves when the sound has been decoded and added */ addSound (soundObject) { return loadSound(soundObject, this.runtime).then(() => { this.editingTarget.addSound(soundObject); this.emitTargetsUpdate(); }); } /** * Rename a sound on the current editing target. * @param {int} soundIndex - the index of the sound to be renamed. * @param {string} newName - the desired new name of the sound (will be modified if already in use). */ renameSound (soundIndex, newName) { this.editingTarget.renameSound(soundIndex, newName); this.emitTargetsUpdate(); } /** * Get a sound buffer from the audio engine. * @param {int} soundIndex - the index of the sound to be got. * @return {AudioBuffer} the sound's audio buffer. */ getSoundBuffer (soundIndex) { const id = this.editingTarget.sprite.sounds[soundIndex].soundId; if (id && this.runtime && this.runtime.audioEngine) { return this.runtime.audioEngine.getSoundBuffer(id); } return null; } /** * Update a sound buffer. * @param {int} soundIndex - the index of the sound to be updated. * @param {AudioBuffer} newBuffer - new audio buffer for the audio engine. * @param {ArrayBuffer} soundEncoding - the new (wav) encoded sound to be stored */ updateSoundBuffer (soundIndex, newBuffer, soundEncoding) { const sound = this.editingTarget.sprite.sounds[soundIndex]; const id = sound ? sound.soundId : null; if (id && this.runtime && this.runtime.audioEngine) { this.runtime.audioEngine.updateSoundBuffer(id, newBuffer); } // Update sound in runtime if (soundEncoding) { // Now that we updated the sound, the format should also be updated // so that the sound can eventually be decoded the right way. // Sounds that were formerly 'adpcm', but were updated in sound editor // will not get decoded by the audio engine correctly unless the format // is updated as below. sound.format = ''; const storage = this.runtime.storage; sound.assetId = storage.builtinHelper.cache( storage.AssetType.Sound, storage.DataFormat.WAV, soundEncoding ); sound.dataFormat = storage.DataFormat.WAV; sound.md5 = `${sound.assetId}.${sound.dataFormat}`; } // If soundEncoding is null, it's because gui had a problem // encoding the updated sound. We don't want to store anything in this // case, and gui should have logged an error. this.emitTargetsUpdate(); } /** * Delete a sound from the current editing target. * @param {int} soundIndex - the index of the sound to be removed. */ deleteSound (soundIndex) { this.editingTarget.deleteSound(soundIndex); } /** * Get an SVG string from storage. * @param {int} costumeIndex - the index of the costume to be got. * @return {string|HTMLImageElement} the costume's SVG string if it's SVG, * an HTMLImageElement if it's a PNG, or null if it couldn't be found or decoded. */ getCostume (costumeIndex) { const id = this.editingTarget.getCostumes()[costumeIndex].assetId; if (!id || !this.runtime || !this.runtime.storage) return null; const format = this.runtime.storage.get(id).dataFormat; if (format === this.runtime.storage.DataFormat.SVG) { return this.runtime.storage.get(id).decodeText(); } else if (format === this.runtime.storage.DataFormat.PNG || format === this.runtime.storage.DataFormat.JPG) { const data = this.runtime.storage.get(id).encodeDataURI(); const image = new Image(); image.src = data; return image; } log.error(`Unhandled format: ${this.runtime.storage.get(id).dataFormat}`); return null; } /** * Update a costume with the given bitmap * @param {int} costumeIndex - the index of the costume to be updated. * @param {HTMLCanvasElement} bitmap - new bitmap for the renderer. * @param {number} rotationCenterX x of point about which the costume rotates, relative to its upper left corner * @param {number} rotationCenterY y of point about which the costume rotates, relative to its upper left corner * @param {number} bitmapResolution 1 for bitmaps that have 1 pixel per unit of stage, 2 for double-resolution bitmaps */ updateBitmap (costumeIndex, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) { const costume = this.editingTarget.getCostumes()[costumeIndex]; if (costume && this.runtime && this.runtime.renderer) { costume.rotationCenterX = rotationCenterX; costume.rotationCenterY = rotationCenterY; this.runtime.renderer.updateBitmapSkin( costume.skinId, bitmap, bitmapResolution, [rotationCenterX, rotationCenterY]); } const storage = this.runtime.storage; costume.assetId = storage.builtinHelper.cache( storage.AssetType.ImageBitmap, storage.DataFormat.PNG, bitmap.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height).data ); costume.dataFormat = storage.DataFormat.PNG; costume.bitmapResolution = bitmapResolution; costume.size = [bitmap.width, bitmap.height]; costume.md5 = `${costume.assetId}.${costume.dataFormat}`; this.emitTargetsUpdate(); } /** * Update a costume with the given SVG * @param {int} costumeIndex - the index of the costume to be updated. * @param {string} svg - new SVG for the renderer. * @param {number} rotationCenterX x of point about which the costume rotates, relative to its upper left corner * @param {number} rotationCenterY y of point about which the costume rotates, relative to its upper left corner */ updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) { const costume = this.editingTarget.getCostumes()[costumeIndex]; if (costume && this.runtime && this.runtime.renderer) { costume.rotationCenterX = rotationCenterX; costume.rotationCenterY = rotationCenterY; this.runtime.renderer.updateSVGSkin(costume.skinId, svg, [rotationCenterX, rotationCenterY]); costume.size = this.runtime.renderer.getSkinSize(costume.skinId); } const storage = this.runtime.storage; costume.assetId = storage.builtinHelper.cache( storage.AssetType.ImageVector, storage.DataFormat.SVG, (new TextEncoder()).encode(svg) ); // If we're in here, we've edited an svg in the vector editor, // so the dataFormat should be 'svg' costume.dataFormat = storage.DataFormat.SVG; costume.md5 = `${costume.assetId}.${costume.dataFormat}`; this.emitTargetsUpdate(); } /** * Add a backdrop to the stage. * @param {string} md5ext - the MD5 and extension of the backdrop to be loaded. * @param {!object} backdropObject Object representing the backdrop. * @property {int} skinId - the ID of the backdrop's render skin, once installed. * @property {number} rotationCenterX - the X component of the backdrop's origin. * @property {number} rotationCenterY - the Y component of the backdrop's origin. * @property {number} [bitmapResolution] - the resolution scale for a bitmap backdrop. * @returns {?Promise} - a promise that resolves when the backdrop has been added */ addBackdrop (md5ext, backdropObject) { return loadCostume(md5ext, backdropObject, this.runtime).then(() => { const stage = this.runtime.getTargetForStage(); stage.addCostume(backdropObject); stage.setCostume(stage.getCostumes().length - 1); }); } /** * Rename a sprite. * @param {string} targetId ID of a target whose sprite to rename. * @param {string} newName New name of the sprite. */ renameSprite (targetId, newName) { const target = this.runtime.getTargetById(targetId); if (target) { if (!target.isSprite()) { throw new Error('Cannot rename non-sprite targets.'); } const sprite = target.sprite; if (!sprite) { throw new Error('No sprite associated with this target.'); } if (newName && RESERVED_NAMES.indexOf(newName) === -1) { const names = this.runtime.targets .filter(runtimeTarget => runtimeTarget.isSprite() && runtimeTarget.id !== target.id) .map(runtimeTarget => runtimeTarget.sprite.name); const oldName = sprite.name; const newUnusedName = StringUtil.unusedName(newName, names); sprite.name = newUnusedName; const allTargets = this.runtime.targets; for (let i = 0; i < allTargets.length; i++) { const currTarget = allTargets[i]; currTarget.blocks.updateAssetName(oldName, newName, 'sprite'); } } this.emitTargetsUpdate(); } else { throw new Error('No target with the provided id.'); } } /** * Delete a sprite and all its clones. * @param {string} targetId ID of a target whose sprite to delete. */ deleteSprite (targetId) { const target = this.runtime.getTargetById(targetId); if (target) { const targetIndexBeforeDelete = this.runtime.targets.map(t => t.id).indexOf(target.id); if (!target.isSprite()) { throw new Error('Cannot delete non-sprite targets.'); } const sprite = target.sprite; if (!sprite) { throw new Error('No sprite associated with this target.'); } this.runtime.requestRemoveMonitorByTargetId(targetId); const currentEditingTarget = this.editingTarget; for (let i = 0; i < sprite.clones.length; i++) { const clone = sprite.clones[i]; this.runtime.stopForTarget(sprite.clones[i]); this.runtime.disposeTarget(sprite.clones[i]); // Ensure editing target is switched if we are deleting it. if (clone === currentEditingTarget) { const nextTargetIndex = Math.min(this.runtime.targets.length - 1, targetIndexBeforeDelete); if (this.runtime.targets.length > 0){ this.setEditingTarget(this.runtime.targets[nextTargetIndex].id); } else { this.editingTarget = null; } } } // Sprite object should be deleted by GC. this.emitTargetsUpdate(); } else { throw new Error('No target with the provided id.'); } } /** * Duplicate a sprite. * @param {string} targetId ID of a target whose sprite to duplicate. * @returns {Promise} Promise that resolves when duplicated target has * been added to the runtime. */ duplicateSprite (targetId) { const target = this.runtime.getTargetById(targetId); if (!target) { throw new Error('No target with the provided id.'); } else if (!target.isSprite()) { throw new Error('Cannot duplicate non-sprite targets.'); } else if (!target.sprite) { throw new Error('No sprite associated with this target.'); } return target.duplicate().then(newTarget => { this.runtime.targets.push(newTarget); this.setEditingTarget(newTarget.id); }); } /** * Set the audio engine for the VM/runtime * @param {!AudioEngine} audioEngine The audio engine to attach */ attachAudioEngine (audioEngine) { this.runtime.attachAudioEngine(audioEngine); } /** * Set the renderer for the VM/runtime * @param {!RenderWebGL} renderer The renderer to attach */ attachRenderer (renderer) { this.runtime.attachRenderer(renderer); } /** * Set the storage module for the VM/runtime * @param {!ScratchStorage} storage The storage module to attach */ attachStorage (storage) { this.runtime.attachStorage(storage); } /** * set the current locale and builtin messages for the VM * @param {[type]} locale current locale * @param {[type]} messages builtin messages map for current locale */ setLocale (locale, messages) { if (locale !== formatMessage.setup().locale) { formatMessage.setup({locale: locale, translations: {[locale]: messages}}); this.extensionManager.refreshBlocks(); } } /** * Handle a Blockly event for the current editing target. * @param {!Blockly.Event} e Any Blockly event. */ blockListener (e) { if (this.editingTarget) { this.editingTarget.blocks.blocklyListen(e, this.runtime); } } /** * Handle a Blockly event for the flyout. * @param {!Blockly.Event} e Any Blockly event. */ flyoutBlockListener (e) { this.runtime.flyoutBlocks.blocklyListen(e, this.runtime); } /** * Handle a Blockly event for the flyout to be passed to the monitor container. * @param {!Blockly.Event} e Any Blockly event. */ monitorBlockListener (e) { // Filter events by type, since monitor blocks only need to listen to these events. // Monitor blocks shouldn't be destroyed when flyout blocks are deleted. if (['create', 'change'].indexOf(e.type) !== -1) { this.runtime.monitorBlocks.blocklyListen(e, this.runtime); } } /** * Handle a Blockly event for the variable map. * @param {!Blockly.Event} e Any Blockly event. */ variableListener (e) { // Filter events by type, since blocks only needs to listen to these // var events. if (['var_create', 'var_rename', 'var_delete'].indexOf(e.type) !== -1) { this.runtime.getTargetForStage().blocks.blocklyListen(e, this.runtime); } } /** * Set an editing target. An editor UI can use this function to switch * between editing different targets, sprites, etc. * After switching the editing target, the VM may emit updates * to the list of targets and any attached workspace blocks * (see `emitTargetsUpdate` and `emitWorkspaceUpdate`). * @param {string} targetId Id of target to set as editing. */ setEditingTarget (targetId) { // Has the target id changed? If not, exit. if (this.editingTarget && targetId === this.editingTarget.id) { return; } const target = this.runtime.getTargetById(targetId); if (target) { this.editingTarget = target; // Emit appropriate UI updates. this.emitTargetsUpdate(); this.emitWorkspaceUpdate(); this.runtime.setEditingTarget(target); } } /** * Called when blocks are dragged from one sprite to another. Adds the blocks to the * workspace of the given target. * @param {!Array} blocks Blocks to add. * @param {!string} targetId Id of target to add blocks to. */ shareBlocksToTarget (blocks, targetId) { const target = this.runtime.getTargetById(targetId); for (let i = 0; i < blocks.length; i++) { target.blocks.createBlock(blocks[i]); } target.blocks.updateTargetSpecificBlocks(target.isStage); } /** * Repopulate the workspace with the blocks of the current editingTarget. This * allows us to get around bugs like gui#413. */ refreshWorkspace () { if (this.editingTarget) { this.emitWorkspaceUpdate(); this.runtime.setEditingTarget(this.editingTarget); } } /** * Emit metadata about available targets. * An editor UI could use this to display a list of targets and show * the currently editing one. */ emitTargetsUpdate () { this.emit('targetsUpdate', { // [[target id, human readable target name], ...]. targetList: this.runtime.targets .filter( // Don't report clones. target => !target.hasOwnProperty('isOriginal') || target.isOriginal ).map( target => target.toJSON() ), // Currently editing target id. editingTarget: this.editingTarget ? this.editingTarget.id : null }); } /** * Emit an Blockly/scratch-blocks compatible XML representation * of the current editing target's blocks. */ emitWorkspaceUpdate () { // Create a list of broadcast message Ids according to the stage variables const stageVariables = this.runtime.getTargetForStage().variables; let messageIds = []; for (const varId in stageVariables) { if (stageVariables[varId].type === Variable.BROADCAST_MESSAGE_TYPE) { messageIds.push(varId); } } // Go through all blocks on all targets, removing referenced // broadcast ids from the list. for (let i = 0; i < this.runtime.targets.length; i++) { const currTarget = this.runtime.targets[i]; const currBlocks = currTarget.blocks._blocks; for (const blockId in currBlocks) { if (currBlocks[blockId].fields.BROADCAST_OPTION) { const id = currBlocks[blockId].fields.BROADCAST_OPTION.id; const index = messageIds.indexOf(id); if (index !== -1) { messageIds = messageIds.slice(0, index) .concat(messageIds.slice(index + 1)); } } } } // Anything left in messageIds is not referenced by a block, so delete it. for (let i = 0; i < messageIds.length; i++) { const id = messageIds[i]; delete this.runtime.getTargetForStage().variables[id]; } const variableMap = Object.assign({}, this.runtime.getTargetForStage().variables, this.editingTarget.variables ); const variables = Object.keys(variableMap).map(k => variableMap[k]); const xmlString = ` ${variables.map(v => v.toXML()).join()} ${this.editingTarget.blocks.toXML()} `; this.emit('workspaceUpdate', {xml: xmlString}); } /** * Get a target id for a drawable id. Useful for interacting with the renderer * @param {int} drawableId The drawable id to request the target id for * @returns {?string} The target id, if found. Will also be null if the target found is the stage. */ getTargetIdForDrawableId (drawableId) { const target = this.runtime.getTargetByDrawableId(drawableId); if (target && target.hasOwnProperty('id') && target.hasOwnProperty('isStage') && !target.isStage) { return target.id; } return null; } /** * Put a target into a "drag" state, during which its X/Y positions will be unaffected * by blocks. * @param {string} targetId The id for the target to put into a drag state */ startDrag (targetId) { const target = this.runtime.getTargetById(targetId); if (target) { this._dragTarget = target; target.startDrag(); } } /** * Remove a target from a drag state, so blocks may begin affecting X/Y position again * @param {string} targetId The id for the target to remove from the drag state */ stopDrag (targetId) { const target = this.runtime.getTargetById(targetId); if (target) { this._dragTarget = null; target.stopDrag(); this.setEditingTarget(target.sprite && target.sprite.clones[0] ? target.sprite.clones[0].id : target.id); } } /** * Post/edit sprite info for the current editing target or the drag target. * @param {object} data An object with sprite info data to set. */ postSpriteInfo (data) { if (this._dragTarget) { this._dragTarget.postSpriteInfo(data); } else { this.editingTarget.postSpriteInfo(data); } } } module.exports = VirtualMachine;