scratch-vm
Version:
Virtual Machine for Scratch 3.0
1,232 lines (1,132 loc) • 64.3 kB
JavaScript
let _TextEncoder;
if (typeof TextEncoder === 'undefined') {
_TextEncoder = require('text-encoding').TextEncoder;
} else {
/* global TextEncoder */
_TextEncoder = TextEncoder;
}
const EventEmitter = require('events');
const JSZip = require('jszip');
const Buffer = require('buffer').Buffer;
const centralDispatch = require('./dispatch/central-dispatch');
const ExtensionManager = require('./extension-support/extension-manager');
const log = require('./util/log');
const MathUtil = require('./util/math-util');
const Runtime = require('./engine/runtime');
const StringUtil = require('./util/string-util');
const formatMessage = require('format-message');
const Variable = require('./engine/variable');
const newBlockIds = require('./util/new-block-ids');
const {loadCostume} = require('./import/load-costume.js');
const {loadSound} = require('./import/load-sound.js');
const {serializeSounds, serializeCostumes} = require('./serialization/serialize-assets');
require('canvas-toBlob');
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_START, () => {
this.emit(Runtime.PROJECT_START);
});
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.PROJECT_CHANGED, () => {
this.emit(Runtime.PROJECT_CHANGED);
});
this.runtime.on(Runtime.VISUAL_REPORT, visualReport => {
this.emit(Runtime.VISUAL_REPORT, visualReport);
});
this.runtime.on(Runtime.TARGETS_UPDATE, emitProjectChanged => {
this.emitTargetsUpdate(emitProjectChanged);
});
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, topBlockId) => {
this.emit(Runtime.BLOCK_DRAG_END, blocks, topBlockId);
});
this.runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
this.emit(Runtime.EXTENSION_ADDED, categoryInfo);
});
this.runtime.on(Runtime.EXTENSION_FIELD_ADDED, (fieldName, fieldImplementation) => {
this.emit(Runtime.EXTENSION_FIELD_ADDED, fieldName, fieldImplementation);
});
this.runtime.on(Runtime.BLOCKSINFO_UPDATE, categoryInfo => {
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
});
this.runtime.on(Runtime.BLOCKS_NEED_UPDATE, () => {
this.emitWorkspaceUpdate();
});
this.runtime.on(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE, () => {
this.extensionManager.refreshBlocks();
});
this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => {
this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info);
});
this.runtime.on(Runtime.USER_PICKED_PERIPHERAL, info => {
this.emit(Runtime.USER_PICKED_PERIPHERAL, info);
});
this.runtime.on(Runtime.PERIPHERAL_CONNECTED, () =>
this.emit(Runtime.PERIPHERAL_CONNECTED)
);
this.runtime.on(Runtime.PERIPHERAL_REQUEST_ERROR, () =>
this.emit(Runtime.PERIPHERAL_REQUEST_ERROR)
);
this.runtime.on(Runtime.PERIPHERAL_DISCONNECTED, () =>
this.emit(Runtime.PERIPHERAL_DISCONNECTED)
);
this.runtime.on(Runtime.PERIPHERAL_CONNECTION_LOST_ERROR, data =>
this.emit(Runtime.PERIPHERAL_CONNECTION_LOST_ERROR, data)
);
this.runtime.on(Runtime.PERIPHERAL_SCAN_TIMEOUT, () =>
this.emit(Runtime.PERIPHERAL_SCAN_TIMEOUT)
);
this.runtime.on(Runtime.MIC_LISTENING, listening => {
this.emit(Runtime.MIC_LISTENING, listening);
});
this.runtime.on(Runtime.RUNTIME_STARTED, () => {
this.emit(Runtime.RUNTIME_STARTED);
});
this.runtime.on(Runtime.HAS_CLOUD_DATA_UPDATE, hasCloudData => {
this.emit(Runtime.HAS_CLOUD_DATA_UPDATE, hasCloudData);
});
this.extensionManager = new ExtensionManager(this.runtime);
// Load core extensions
for (const id of CORE_EXTENSIONS) {
this.extensionManager.loadExtensionIdSync(id);
}
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();
}
/**
* Quit the VM, clearing any handles which might keep the process alive.
* Do not use the runtime after calling this method. This method is meant for test shutdown.
*/
quit () {
this.runtime.quit();
}
/**
* "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;
if (this.runtime.turboMode) {
this.emit(Runtime.TURBO_MODE_ON);
} else {
this.emit(Runtime.TURBO_MODE_OFF);
}
}
/**
* 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(false /* Don't emit project change */);
}
/**
* 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' || key === 'blockContainer') 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);
}
}
setVideoProvider (videoProvider) {
this.runtime.ioDevices.video.setProvider(videoProvider);
}
setCloudProvider (cloudProvider) {
this.runtime.ioDevices.cloud.setProvider(cloudProvider);
}
/**
* Tell the specified extension to scan for a peripheral.
* @param {string} extensionId - the id of the extension.
*/
scanForPeripheral (extensionId) {
this.runtime.scanForPeripheral(extensionId);
}
/**
* Connect to the extension's specified peripheral.
* @param {string} extensionId - the id of the extension.
* @param {number} peripheralId - the id of the peripheral.
*/
connectPeripheral (extensionId, peripheralId) {
this.runtime.connectPeripheral(extensionId, peripheralId);
}
/**
* Disconnect from the extension's connected peripheral.
* @param {string} extensionId - the id of the extension.
*/
disconnectPeripheral (extensionId) {
this.runtime.disconnectPeripheral(extensionId);
}
/**
* Returns whether the extension has a currently connected peripheral.
* @param {string} extensionId - the id of the extension.
* @return {boolean} - whether the extension has a connected peripheral.
*/
getPeripheralIsConnected (extensionId) {
return this.runtime.getPeripheralIsConnected(extensionId);
}
/**
* 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) => {
const validate = require('scratch-parser');
// The second argument of false below indicates to the validator that the
// input should be parsed/validated as an entire project (and not a single sprite)
validate(input, false, (error, res) => {
if (error) return reject(error);
resolve(res);
});
})
.catch(error => {
const {SB1File, ValidationError} = require('scratch-sb1-converter');
try {
const sb1 = new SB1File(input);
const json = sb1.json;
json.projectVersion = 2;
return Promise.resolve([json, sb1.zip]);
} catch (sb1Error) {
if (sb1Error instanceof ValidationError) {
// The input does not validate as a Scratch 1 file.
} else {
// The project appears to be a Scratch 1 file but it
// could not be successfully translated into a Scratch 2
// project.
return Promise.reject(sb1Error);
}
}
// Throw original error since the input does not appear to be
// an SB1File.
return Promise.reject(error);
});
return validationPromise
.then(validatedInput => this.deserializeProject(validatedInput[0], validatedInput[1]))
.then(() => this.runtime.handleProjectLoaded())
.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 => {
if (!projectAsset) {
log.error(`Failed to fetch project with id: ${id}`);
return null;
}
return vm.loadProject(projectAsset.data);
});
}
/**
* @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);
this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip);
return zip.generateAsync({
type: 'blob',
mimeType: 'application/x.scratch.sb3',
compression: 'DEFLATE',
compressionOptions: {
level: 6 // Tradeoff between best speed (1) and best compression (9)
}
});
}
/*
* @type {Array<object>} Array of all costumes and sounds currently in the runtime
*/
get assets () {
return this.runtime.targets.reduce((acc, target) => (
acc
.concat(target.sprite.sounds.map(sound => sound.asset))
.concat(target.sprite.costumes.map(costume => costume.asset))
), []);
}
_addFileDescsToZip (fileDescs, zip) {
for (let i = 0; i < fileDescs.length; i++) {
const currFileDesc = fileDescs[i];
zip.file(currFileDesc.fileName, currFileDesc.fileContent);
}
}
/**
* Exports a sprite in the sprite3 format.
* @param {string} targetId ID of the target to export
* @param {string=} optZipType Optional type that the resulting
* zip should be outputted in. Options are: base64, binarystring,
* array, uint8array, arraybuffer, blob, or nodebuffer. Defaults to
* blob if argument not provided.
* See https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html#type-option
* for more information about these options.
* @return {object} A generated zip of the sprite and its assets in the format
* specified by optZipType or blob by default.
*/
exportSprite (targetId, optZipType) {
const soundDescs = serializeSounds(this.runtime, targetId);
const costumeDescs = serializeCostumes(this.runtime, targetId);
const spriteJson = this.toJSON(targetId);
const zip = new JSZip();
zip.file('sprite.json', spriteJson);
this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip);
return zip.generateAsync({
type: typeof optZipType === 'string' ? optZipType : 'blob',
mimeType: 'application/x.scratch.sprite3',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
});
}
/**
* Export project or sprite as a Scratch 3.0 JSON representation.
* @param {string=} optTargetId - Optional id of a sprite to serialize
* @return {string} Serialized state of the runtime.
*/
toJSON (optTargetId) {
const sb3 = require('./serialization/sb3');
return StringUtil.stringify(sb3.serialize(this.runtime, optTargetId));
}
// 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();
if (typeof performance !== 'undefined') {
performance.mark('scratch-vm-deserialize-start');
}
const runtime = this.runtime;
const deserializePromise = function () {
const projectVersion = projectJSON.projectVersion;
if (projectVersion === 2) {
const sb2 = require('./serialization/sb2');
return sb2.deserialize(projectJSON, runtime, false, zip);
}
if (projectVersion === 3) {
const sb3 = require('./serialization/sb3');
return sb3.deserialize(projectJSON, runtime, zip);
}
return Promise.reject('Unable to verify Scratch Project version.');
};
return deserializePromise()
.then(({targets, extensions}) => {
if (typeof performance !== 'undefined') {
performance.mark('scratch-vm-deserialize-end');
performance.measure('scratch-vm-deserialize',
'scratch-vm-deserialize-start', 'scratch-vm-deserialize-end');
}
return this.installTargets(targets, extensions, true);
});
}
/**
* Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets.
* @param {Array.<Target>} targets - the targets to be installed
* @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets
* @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite.
* @returns {Promise} resolved once targets have been installed
*/
installTargets (targets, extensions, wholeProject) {
const extensionPromises = [];
extensions.extensionIDs.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL));
}
});
targets = targets.filter(target => !!target);
return Promise.all(extensionPromises).then(() => {
targets.forEach(target => {
this.runtime.addTarget(target);
(/** @type RenderedTarget */ target).updateAllDrawableProperties();
// Ensure unique sprite name
if (target.isSprite()) this.renameSprite(target.id, target.getName());
});
// Sort the executable targets by layerOrder.
// Remove layerOrder property after use.
this.runtime.executableTargets.sort((a, b) => a.layerOrder - b.layerOrder);
targets.forEach(target => {
delete target.layerOrder;
});
// 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];
}
if (!wholeProject) {
this.editingTarget.fixUpVariableReferences();
}
// Update the VM user's knowledge of targets and blocks on the workspace.
this.emitTargetsUpdate(false /* Don't emit project change */);
this.emitWorkspaceUpdate();
this.runtime.setEditingTarget(this.editingTarget);
this.runtime.ioDevices.cloud.setStage(this.runtime.getTargetForStage());
});
}
/**
* Add a sprite, this could be .sprite2 or .sprite3. Unpack and validate
* such a file first.
* @param {string | object} input A json string, object, or ArrayBuffer representing the project to load.
* @return {!Promise} Promise that resolves after targets are installed.
*/
addSprite (input) {
const errorPrefix = 'Sprite Upload Error:';
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) => {
const validate = require('scratch-parser');
// The second argument of true below indicates to the parser/validator
// that the given input should be treated as a single sprite and not
// an entire project
validate(input, true, (error, res) => {
if (error) return reject(error);
resolve(res);
});
});
return validationPromise
.then(validatedInput => {
const projectVersion = validatedInput[0].projectVersion;
if (projectVersion === 2) {
return this._addSprite2(validatedInput[0], validatedInput[1]);
}
if (projectVersion === 3) {
return this._addSprite3(validatedInput[0], validatedInput[1]);
}
return Promise.reject(`${errorPrefix} Unable to verify sprite version.`);
})
.then(() => this.runtime.emitProjectChanged())
.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(`${errorPrefix} ${error}`);
});
}
/**
* Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format.
* @param {object} sprite Object representing 2.0 sprite to be added.
* @param {?ArrayBuffer} zip Optional zip of assets being referenced by json
* @returns {Promise} Promise that resolves after the sprite is added
*/
_addSprite2 (sprite, zip) {
// Validate & parse
const sb2 = require('./serialization/sb2');
return sb2.deserialize(sprite, this.runtime, true, zip)
.then(({targets, extensions}) =>
this.installTargets(targets, extensions, false));
}
/**
* Add a single sb3 sprite.
* @param {object} sprite Object rperesenting 3.0 sprite to be added.
* @param {?ArrayBuffer} zip Optional zip of assets being referenced by target json
* @returns {Promise} Promise that resolves after the sprite is added
*/
_addSprite3 (sprite, zip) {
// Validate & parse
const sb3 = require('./serialization/sb3');
return sb3
.deserialize(sprite, this.runtime, zip, 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.
* @param {string} optTargetId - the id of the target to add to, if not the editing target.
* @param {string} optVersion - if this is 2, load costume as sb2, otherwise load costume as sb3.
* @returns {?Promise} - a promise that resolves when the costume has been added
*/
addCostume (md5ext, costumeObject, optTargetId, optVersion) {
const target = optTargetId ? this.runtime.getTargetById(optTargetId) :
this.editingTarget;
if (target) {
return loadCostume(md5ext, costumeObject, this.runtime, optVersion).then(() => {
target.addCostume(costumeObject);
target.setCostume(
target.getCostumes().length - 1
);
this.runtime.emitProjectChanged();
});
}
// If the target cannot be found by id, return a rejected promise
return Promise.reject();
}
/**
* Add a costume loaded from the library 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
*/
addCostumeFromLibrary (md5ext, costumeObject) {
if (!this.editingTarget) return Promise.reject();
return this.addCostume(md5ext, costumeObject, this.editingTarget.id, 2 /* optVersion */);
}
/**
* 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, this.editingTarget.sprite.soundBank).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.
* @return {?function} A function to restore the deleted costume, or null,
* if no costume was deleted.
*/
deleteCostume (costumeIndex) {
const deletedCostume = this.editingTarget.deleteCostume(costumeIndex);
if (deletedCostume) {
const target = this.editingTarget;
this.runtime.emitProjectChanged();
return () => {
target.addCostume(deletedCostume);
this.emitTargetsUpdate();
};
}
return null;
}
/**
* Add a sound to the current editing target.
* @param {!object} soundObject Object representing the costume.
* @param {string} optTargetId - the id of the target to add to, if not the editing target.
* @returns {?Promise} - a promise that resolves when the sound has been decoded and added
*/
addSound (soundObject, optTargetId) {
const target = optTargetId ? this.runtime.getTargetById(optTargetId) :
this.editingTarget;
if (target) {
return loadSound(soundObject, this.runtime, target.sprite.soundBank).then(() => {
target.addSound(soundObject);
this.emitTargetsUpdate();
});
}
// If the target cannot be found by id, return a rejected promise
return new Promise.reject();
}
/**
* 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.editingTarget.sprite.soundBank.getSoundPlayer(id).buffer;
}
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];
if (sound && sound.broken) delete sound.broken;
const id = sound ? sound.soundId : null;
if (id && this.runtime && this.runtime.audioEngine) {
this.editingTarget.sprite.soundBank.getSoundPlayer(id).buffer = 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.asset = storage.createAsset(
storage.AssetType.Sound,
storage.DataFormat.WAV,
soundEncoding,
null,
true // generate md5
);
sound.assetId = sound.asset.assetId;
sound.dataFormat = storage.DataFormat.WAV;
sound.md5 = `${sound.assetId}.${sound.dataFormat}`;
sound.sampleCount = newBuffer.length;
sound.rate = newBuffer.sampleRate;
}
// 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.
* @return {?Function} A function to restore the sound that was deleted,
* or null, if no sound was deleted.
*/
deleteSound (soundIndex) {
const target = this.editingTarget;
const deletedSound = this.editingTarget.deleteSound(soundIndex);
if (deletedSound) {
this.runtime.emitProjectChanged();
const restoreFun = () => {
target.addSound(deletedSound);
this.emitTargetsUpdate();
};
return restoreFun;
}
return null;
}
/**
* Get a string representation of the image from storage.
* @param {int} costumeIndex - the index of the costume to be got.
* @return {string} the costume's SVG string if it's SVG,
* a dataURI if it's a PNG or JPG, or null if it couldn't be found or decoded.
*/
getCostume (costumeIndex) {
const asset = this.editingTarget.getCostumes()[costumeIndex].asset;
if (!asset || !this.runtime || !this.runtime.storage) return null;
const format = asset.dataFormat;
if (format === this.runtime.storage.DataFormat.SVG) {
return asset.decodeText();
} else if (format === this.runtime.storage.DataFormat.PNG ||
format === this.runtime.storage.DataFormat.JPG) {
return asset.encodeDataURI();
}
log.error(`Unhandled format: ${asset.dataFormat}`);
return null;
}
/**
* Update a costume with the given bitmap
* @param {!int} costumeIndex - the index of the costume to be updated.
* @param {!ImageData} 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)) return;
if (costume && costume.broken) delete costume.broken;
costume.rotationCenterX = rotationCenterX;
costume.rotationCenterY = rotationCenterY;
// If the bitmap originally had a zero width or height, use that value
const bitmapWidth = bitmap.sourceWidth === 0 ? 0 : bitmap.width;
const bitmapHeight = bitmap.sourceHeight === 0 ? 0 : bitmap.height;
// @todo: updateBitmapSkin does not take ImageData
const canvas = document.createElement('canvas');
canvas.width = bitmapWidth;
canvas.height = bitmapHeight;
const context = canvas.getContext('2d');
context.putImageData(bitmap, 0, 0);
// Divide by resolution because the renderer's definition of the rotation center
// is the rotation center divided by the bitmap resolution
this.runtime.renderer.updateBitmapSkin(
costume.skinId,
canvas,
bitmapResolution,
[rotationCenterX / bitmapResolution, rotationCenterY / bitmapResolution]
);
// @todo there should be a better way to get from ImageData to a decodable storage format
canvas.toBlob(blob => {
const reader = new FileReader();
reader.addEventListener('loadend', () => {
const storage = this.runtime.storage;
costume.dataFormat = storage.DataFormat.PNG;
costume.bitmapResolution = bitmapResolution;
costume.size = [bitmapWidth, bitmapHeight];
costume.asset = storage.createAsset(
storage.AssetType.ImageBitmap,
costume.dataFormat,
Buffer.from(reader.result),
null, // id
true // generate md5
);
costume.assetId = costume.asset.assetId;
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
this.emitTargetsUpdate();
});
// Bitmaps with a zero width or height return null for their blob
if (blob){
reader.readAsArrayBuffer(blob);
}
});
}
/**
* 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 && costume.broken) delete costume.broken;
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;
// 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.bitmapResolution = 1;
costume.asset = storage.createAsset(
storage.AssetType.ImageVector,
costume.dataFormat,
(new _TextEncoder()).encode(svg),
null,
true // generate md5
);
costume.assetId = costume.asset.assetId;
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);
this.runtime.emitProjectChanged();
});
}
/**
* 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');
}
if (newUnusedName !== oldName) 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.
* @return {Function} Returns a function to restore the sprite that was deleted
*/
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.');
}
const spritePromise = this.exportSprite(targetId, 'uint8array');
const restoreSprite = () => spritePromise.then(spriteBuffer => this.addSprite(spriteBuffer));
// Remove monitors from the runtime state and remove the
// target-specific monitored blocks (e.g. local variables)
target.deleteMonitors();
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();
return restoreSprite;
}
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.addTarget(newTarget);
newTarget.goBehindOther(target);
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);
}
/**
* @returns {RenderWebGL} The renderer attached to the vm
*/
get renderer () {
return this.runtime && this.runtime.renderer;
}
// @deprecated
attachV2SVGAdapter () {
}
/**
* Set the bitmap adapter for the VM/runtime, which converts scratch 2
* bitmaps to scratch 3 bitmaps. (Scratch 3 bitmaps are all bitmap resolution 2)
* @param {!function} bitmapAdapter The adapter to attach
*/
attachV2BitmapAdapter (bitmapAdapter) {
this.runtime.attachV2BitmapAdapter(bitmapAdapter);
}
/**
* 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 {!string} locale current locale
* @param {!object} messages builtin messages map for current locale
* @returns {Promise} Promise that resolves when all the blocks have been
* updated for a new locale (or empty if locale hasn't changed.)
*/
setLocale (locale, messages) {
if (locale !== formatMessage.setup().locale) {
formatMessage.setup({locale: locale, translations: {[locale]: messages}});
}
return this.extensionManager.refreshBlocks();
}
/**
* get the current locale for the VM
* @returns {string} the current locale in the VM
*/
getLocale () {
return formatMessage.setup().locale;
}
/**
* 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);
}
}
/**
* Handle a Blockly event for the flyout.
* @param {!Blockly.Event} e Any Blockly event.
*/
flyoutBlockListener (e) {
this.runtime.flyoutBlocks.blocklyListen(e);
}
/**
* 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);
}
}
/**
* 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);
}
}
/**
* Delete all of the flyout blocks.
*/
clearFlyoutBlocks () {
this.runtime.flyoutBlocks.deleteAllBlocks();
}
/**
* 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(false /* Don't emit project change */);
this.emitWorkspaceUpdate();
this.runtime.setEditingTarget(target);
}
}
/**
* Called when blocks are dragge