2017-04-17 15:10:04 -04:00
|
|
|
const EventEmitter = require('events');
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-08-04 14:25:17 -04:00
|
|
|
const centralDispatch = require('./dispatch/central-dispatch');
|
2017-08-29 14:43:09 -04:00
|
|
|
const ExtensionManager = require('./extension-support/extension-manager');
|
2017-04-17 15:10:04 -04:00
|
|
|
const log = require('./util/log');
|
|
|
|
const Runtime = require('./engine/runtime');
|
2017-04-26 11:44:53 -04:00
|
|
|
const sb2 = require('./serialization/sb2');
|
|
|
|
const sb3 = require('./serialization/sb3');
|
2017-04-17 15:10:04 -04:00
|
|
|
const StringUtil = require('./util/string-util');
|
2017-03-20 12:52:57 -04:00
|
|
|
|
2017-09-11 09:42:16 -04:00
|
|
|
const {loadCostume} = require('./import/load-costume.js');
|
|
|
|
const {loadSound} = require('./import/load-sound.js');
|
2017-03-27 15:04:44 -04:00
|
|
|
|
2017-04-17 15:10:04 -04:00
|
|
|
const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_'];
|
2017-01-13 16:34:26 -05:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles connections between blocks, stage, and extensions.
|
|
|
|
* @constructor
|
|
|
|
*/
|
2017-04-17 19:42:48 -04:00
|
|
|
class VirtualMachine extends EventEmitter {
|
|
|
|
constructor () {
|
|
|
|
super();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* VM runtime, to store blocks, I/O devices, sprites/targets, etc.
|
|
|
|
* @type {!Runtime}
|
|
|
|
*/
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime = new Runtime();
|
2017-08-04 14:25:17 -04:00
|
|
|
centralDispatch.setService('runtime', this.runtime).catch(e => {
|
|
|
|
log.error(`Failed to register runtime service: ${JSON.stringify(e)}`);
|
|
|
|
});
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* The "currently editing"/selected target ID for the VM.
|
|
|
|
* Block events from any Blockly workspace are routed to this target.
|
2017-11-03 14:17:16 -04:00
|
|
|
* @type {Target}
|
2017-04-17 19:42:48 -04:00
|
|
|
*/
|
2017-05-12 11:42:22 -04:00
|
|
|
this.editingTarget = null;
|
2017-04-17 19:42:48 -04:00
|
|
|
// Runtime emits are passed along as VM emits.
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.SCRIPT_GLOW_ON, glowData => {
|
|
|
|
this.emit(Runtime.SCRIPT_GLOW_ON, glowData);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.SCRIPT_GLOW_OFF, glowData => {
|
|
|
|
this.emit(Runtime.SCRIPT_GLOW_OFF, glowData);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.BLOCK_GLOW_ON, glowData => {
|
|
|
|
this.emit(Runtime.BLOCK_GLOW_ON, glowData);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.BLOCK_GLOW_OFF, glowData => {
|
|
|
|
this.emit(Runtime.BLOCK_GLOW_OFF, glowData);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.PROJECT_RUN_START, () => {
|
|
|
|
this.emit(Runtime.PROJECT_RUN_START);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.PROJECT_RUN_STOP, () => {
|
|
|
|
this.emit(Runtime.PROJECT_RUN_STOP);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.VISUAL_REPORT, visualReport => {
|
|
|
|
this.emit(Runtime.VISUAL_REPORT, visualReport);
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-12 11:42:22 -04:00
|
|
|
this.runtime.on(Runtime.TARGETS_UPDATE, () => {
|
|
|
|
this.emitTargetsUpdate();
|
2017-04-17 19:42:48 -04:00
|
|
|
});
|
2017-05-15 10:45:20 -04:00
|
|
|
this.runtime.on(Runtime.MONITORS_UPDATE, monitorList => {
|
|
|
|
this.emit(Runtime.MONITORS_UPDATE, monitorList);
|
2017-05-10 15:47:06 -04:00
|
|
|
});
|
2017-09-04 14:06:31 -04:00
|
|
|
this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
|
|
|
|
this.emit(Runtime.EXTENSION_ADDED, blocksInfo);
|
2017-08-29 14:43:09 -04:00
|
|
|
});
|
|
|
|
|
2017-10-04 15:16:27 -04:00
|
|
|
this.extensionManager = new ExtensionManager(this.runtime);
|
2017-04-17 19:42:48 -04:00
|
|
|
|
|
|
|
this.blockListener = this.blockListener.bind(this);
|
|
|
|
this.flyoutBlockListener = this.flyoutBlockListener.bind(this);
|
2017-05-08 09:53:16 -04:00
|
|
|
this.monitorBlockListener = this.monitorBlockListener.bind(this);
|
2017-06-15 17:29:15 -04:00
|
|
|
this.variableListener = this.variableListener.bind(this);
|
2017-04-17 19:42:48 -04:00
|
|
|
}
|
|
|
|
|
2017-01-13 16:34:26 -05:00
|
|
|
/**
|
2017-04-17 19:42:48 -04:00
|
|
|
* Start running the VM - do this before anything else.
|
2017-01-13 16:34:26 -05:00
|
|
|
*/
|
2017-04-17 19:42:48 -04:00
|
|
|
start () {
|
|
|
|
this.runtime.start();
|
|
|
|
}
|
|
|
|
|
2017-01-13 16:34:26 -05:00
|
|
|
/**
|
2017-04-17 19:42:48 -04:00
|
|
|
* "Green flag" handler - start all threads starting with a green flag.
|
2017-01-13 16:34:26 -05:00
|
|
|
*/
|
2017-04-17 19:42:48 -04:00
|
|
|
greenFlag () {
|
|
|
|
this.runtime.greenFlag();
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Stop all threads and running activities.
|
|
|
|
*/
|
|
|
|
stopAll () {
|
|
|
|
this.runtime.stopAll();
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Clear out current running project data.
|
|
|
|
*/
|
|
|
|
clear () {
|
|
|
|
this.runtime.dispose();
|
|
|
|
this.editingTarget = null;
|
|
|
|
this.emitTargetsUpdate();
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
});
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Load a project from a Scratch 2.0 JSON representation.
|
|
|
|
* @param {?string} json JSON string representing the project.
|
2017-04-19 17:54:52 -04:00
|
|
|
* @return {!Promise} Promise that resolves after targets are installed.
|
2017-04-17 19:42:48 -04:00
|
|
|
*/
|
|
|
|
loadProject (json) {
|
|
|
|
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 3.0.
|
2017-04-26 16:50:53 -04:00
|
|
|
return this.fromJSON(json);
|
2017-04-17 19:42:48 -04:00
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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) {
|
2017-04-20 18:30:38 -04:00
|
|
|
const storage = this.runtime.storage;
|
|
|
|
if (!storage) {
|
2017-04-17 19:42:48 -04:00
|
|
|
log.error('No storage module present; cannot load project: ', id);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const vm = this;
|
2017-04-20 18:30:38 -04:00
|
|
|
const promise = storage.load(storage.AssetType.Project, id);
|
2017-04-17 19:42:48 -04:00
|
|
|
promise.then(projectAsset => {
|
|
|
|
vm.loadProject(projectAsset.decodeText());
|
|
|
|
});
|
2017-03-13 18:06:28 -04:00
|
|
|
}
|
2017-01-27 13:44:48 -05:00
|
|
|
|
2017-04-26 11:44:53 -04:00
|
|
|
/**
|
2017-04-26 16:50:53 -04:00
|
|
|
* @returns {string} Project in a Scratch 3.0 JSON representation.
|
2017-04-26 11:44:53 -04:00
|
|
|
*/
|
|
|
|
saveProjectSb3 () {
|
|
|
|
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 2.0.
|
|
|
|
return this.toJSON();
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
|
2017-04-26 11:44:53 -04:00
|
|
|
/**
|
|
|
|
* Export project as a Scratch 3.0 JSON representation.
|
|
|
|
* @return {string} Serialized state of the runtime.
|
|
|
|
*/
|
|
|
|
toJSON () {
|
|
|
|
return JSON.stringify(sb3.serialize(this.runtime));
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-26 11:44:53 -04:00
|
|
|
/**
|
|
|
|
* Load a project from a Scratch JSON representation.
|
|
|
|
* @param {string} json JSON string representing a project.
|
2017-04-26 16:50:53 -04:00
|
|
|
* @returns {Promise} Promise that resolves after the project has loaded
|
2017-04-26 11:44:53 -04:00
|
|
|
*/
|
|
|
|
fromJSON (json) {
|
|
|
|
// Clear the current runtime
|
|
|
|
this.clear();
|
|
|
|
|
|
|
|
// Validate & parse
|
2017-05-11 02:22:12 -04:00
|
|
|
if (typeof json !== 'string') {
|
|
|
|
log.error('Failed to parse project. Non-string supplied to fromJSON.');
|
|
|
|
return;
|
|
|
|
}
|
2017-04-26 11:44:53 -04:00
|
|
|
json = JSON.parse(json);
|
2017-05-11 02:22:12 -04:00
|
|
|
if (typeof json !== 'object') {
|
|
|
|
log.error('Failed to parse project. JSON supplied to fromJSON is not an object.');
|
|
|
|
return;
|
|
|
|
}
|
2017-04-26 11:44:53 -04:00
|
|
|
|
|
|
|
// Establish version, deserialize, and load into runtime
|
|
|
|
// @todo Support Scratch 1.4
|
|
|
|
// @todo This is an extremely naïve / dangerous way of determining version.
|
|
|
|
// See `scratch-parser` for a more sophisticated validation
|
|
|
|
// methodology that should be adapted for use here
|
2017-04-26 16:50:53 -04:00
|
|
|
let deserializer;
|
|
|
|
if ((typeof json.meta !== 'undefined') && (typeof json.meta.semver !== 'undefined')) {
|
|
|
|
deserializer = sb3;
|
2017-04-26 11:44:53 -04:00
|
|
|
} else {
|
2017-04-26 16:50:53 -04:00
|
|
|
deserializer = sb2;
|
2017-04-26 11:44:53 -04:00
|
|
|
}
|
2017-01-27 20:05:54 -05:00
|
|
|
|
2017-11-03 14:17:16 -04:00
|
|
|
return deserializer.deserialize(json, this.runtime)
|
|
|
|
.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.<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.
|
2017-11-03 15:50:37 -04:00
|
|
|
* @returns {Promise} resolved once targets have been installed
|
2017-11-03 14:17:16 -04:00
|
|
|
*/
|
|
|
|
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);
|
|
|
|
|
2017-11-03 15:50:37 -04:00
|
|
|
return Promise.all(extensionPromises).then(() => {
|
2017-11-03 14:17:16 -04:00
|
|
|
if (wholeProject) {
|
|
|
|
this.clear();
|
2017-04-26 16:50:53 -04:00
|
|
|
}
|
2017-11-03 14:17:16 -04:00
|
|
|
targets.forEach(target => {
|
|
|
|
this.runtime.targets.push(target);
|
|
|
|
(/** @type RenderedTarget */ target).updateAllDrawableProperties();
|
|
|
|
});
|
2017-04-26 16:50:53 -04:00
|
|
|
// Select the first target for editing, e.g., the first sprite.
|
2017-11-03 14:17:16 -04:00
|
|
|
if (wholeProject && (targets.length > 1)) {
|
|
|
|
this.editingTarget = targets[1];
|
2017-05-11 02:24:10 -04:00
|
|
|
} else {
|
2017-11-03 14:17:16 -04:00
|
|
|
this.editingTarget = targets[0];
|
2017-05-11 02:24:10 -04:00
|
|
|
}
|
2017-01-27 20:05:54 -05:00
|
|
|
|
2017-04-26 16:50:53 -04:00
|
|
|
// Update the VM user's knowledge of targets and blocks on the workspace.
|
|
|
|
this.emitTargetsUpdate();
|
|
|
|
this.emitWorkspaceUpdate();
|
|
|
|
this.runtime.setEditingTarget(this.editingTarget);
|
|
|
|
});
|
2017-04-26 11:44:53 -04:00
|
|
|
}
|
2017-01-27 20:05:54 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format.
|
|
|
|
* @param {string} json JSON string representing the sprite.
|
2017-04-26 16:50:53 -04:00
|
|
|
* @returns {Promise} Promise that resolves after the sprite is added
|
2017-04-17 19:42:48 -04:00
|
|
|
*/
|
|
|
|
addSprite2 (json) {
|
2017-04-27 17:49:57 -04:00
|
|
|
// Validate & parse
|
2017-05-11 02:22:12 -04:00
|
|
|
if (typeof json !== 'string') {
|
|
|
|
log.error('Failed to parse sprite. Non-string supplied to addSprite2.');
|
|
|
|
return;
|
|
|
|
}
|
2017-04-27 17:49:57 -04:00
|
|
|
json = JSON.parse(json);
|
2017-05-11 02:22:12 -04:00
|
|
|
if (typeof json !== 'object') {
|
|
|
|
log.error('Failed to parse sprite. JSON supplied to addSprite2 is not an object.');
|
|
|
|
return;
|
|
|
|
}
|
2017-04-27 17:49:57 -04:00
|
|
|
|
2017-11-03 14:17:16 -04:00
|
|
|
return sb2.deserialize(json, this.runtime, true)
|
|
|
|
.then(({targets, extensions}) =>
|
|
|
|
this.installTargets(targets, extensions, false));
|
2017-04-17 19:42:48 -04:00
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
addCostume (md5ext, costumeObject) {
|
|
|
|
loadCostume(md5ext, costumeObject, this.runtime).then(() => {
|
2017-07-25 10:32:25 -04:00
|
|
|
this.editingTarget.addCostume(costumeObject);
|
2017-04-17 19:42:48 -04:00
|
|
|
this.editingTarget.setCostume(
|
|
|
|
this.editingTarget.sprite.costumes.length - 1
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-07-07 08:19:32 -04:00
|
|
|
/**
|
|
|
|
* 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) {
|
2017-07-25 10:32:25 -04:00
|
|
|
this.editingTarget.renameCostume(costumeIndex, newName);
|
2017-07-07 08:19:32 -04:00
|
|
|
this.emitTargetsUpdate();
|
|
|
|
}
|
|
|
|
|
2017-05-15 08:30:02 -04:00
|
|
|
/**
|
|
|
|
* Delete a costume from the current editing target.
|
|
|
|
* @param {int} costumeIndex - the index of the costume to be removed.
|
|
|
|
*/
|
|
|
|
deleteCostume (costumeIndex) {
|
2017-05-16 11:01:50 -04:00
|
|
|
this.editingTarget.deleteCostume(costumeIndex);
|
2017-05-15 08:30:02 -04:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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(() => {
|
2017-07-25 10:32:25 -04:00
|
|
|
this.editingTarget.addSound(soundObject);
|
2017-04-17 19:42:48 -04:00
|
|
|
this.emitTargetsUpdate();
|
|
|
|
});
|
|
|
|
}
|
2017-04-03 09:33:23 -04:00
|
|
|
|
2017-07-07 08:19:32 -04:00
|
|
|
/**
|
|
|
|
* 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) {
|
2017-07-25 10:32:25 -04:00
|
|
|
this.editingTarget.renameSound(soundIndex, newName);
|
2017-07-07 08:19:32 -04:00
|
|
|
this.emitTargetsUpdate();
|
|
|
|
}
|
|
|
|
|
2017-07-25 12:39:30 -04:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
updateSoundBuffer (soundIndex, newBuffer) {
|
|
|
|
const id = this.editingTarget.sprite.sounds[soundIndex].soundId;
|
|
|
|
if (id && this.runtime && this.runtime.audioEngine) {
|
|
|
|
this.runtime.audioEngine.updateSoundBuffer(id, newBuffer);
|
|
|
|
}
|
|
|
|
this.emitTargetsUpdate();
|
|
|
|
}
|
|
|
|
|
2017-05-15 08:30:02 -04:00
|
|
|
/**
|
|
|
|
* Delete a sound from the current editing target.
|
|
|
|
* @param {int} soundIndex - the index of the sound to be removed.
|
|
|
|
*/
|
|
|
|
deleteSound (soundIndex) {
|
2017-05-16 11:01:50 -04:00
|
|
|
this.editingTarget.deleteSound(soundIndex);
|
2017-05-15 08:30:02 -04:00
|
|
|
}
|
|
|
|
|
2017-08-30 18:42:44 -04:00
|
|
|
/**
|
2017-08-31 11:47:07 -04:00
|
|
|
* Get an SVG string from storage.
|
2017-08-31 13:41:47 -04:00
|
|
|
* @param {int} costumeIndex - the index of the costume to be got.
|
2017-08-30 18:42:44 -04:00
|
|
|
* @return {string} the costume's SVG string, or null if it's not an SVG costume.
|
|
|
|
*/
|
|
|
|
getCostumeSvg (costumeIndex) {
|
|
|
|
const id = this.editingTarget.sprite.costumes[costumeIndex].assetId;
|
|
|
|
if (id && this.runtime && this.runtime.storage &&
|
|
|
|
this.runtime.storage.get(id).dataFormat === 'svg') {
|
|
|
|
return this.runtime.storage.get(id).decodeText();
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2017-09-05 17:49:30 -04:00
|
|
|
* @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
|
2017-08-30 18:42:44 -04:00
|
|
|
*/
|
2017-09-05 16:51:03 -04:00
|
|
|
updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) {
|
2017-08-30 18:42:44 -04:00
|
|
|
const costume = this.editingTarget.sprite.costumes[costumeIndex];
|
|
|
|
if (costume && this.runtime && this.runtime.renderer) {
|
2017-09-05 17:53:27 -04:00
|
|
|
costume.rotationCenterX = rotationCenterX;
|
|
|
|
costume.rotationCenterY = rotationCenterY;
|
2017-09-05 16:51:03 -04:00
|
|
|
this.runtime.renderer.updateSVGSkin(costume.skinId, svg, [rotationCenterX, rotationCenterY]);
|
2017-08-30 18:42:44 -04:00
|
|
|
}
|
2017-10-13 17:35:56 -04:00
|
|
|
const storage = this.runtime.storage;
|
|
|
|
costume.assetId = storage.builtinHelper.cache(
|
|
|
|
storage.AssetType.ImageVector,
|
|
|
|
storage.DataFormat.SVG,
|
|
|
|
(new TextEncoder()).encode(svg)
|
|
|
|
);
|
|
|
|
this.emitTargetsUpdate();
|
2017-08-30 18:42:44 -04:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
addBackdrop (md5ext, backdropObject) {
|
|
|
|
loadCostume(md5ext, backdropObject, this.runtime).then(() => {
|
|
|
|
const stage = this.runtime.getTargetForStage();
|
|
|
|
stage.sprite.costumes.push(backdropObject);
|
|
|
|
stage.setCostume(stage.sprite.costumes.length - 1);
|
|
|
|
});
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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
|
2017-05-18 09:12:12 -04:00
|
|
|
.filter(runtimeTarget => runtimeTarget.isSprite() && runtimeTarget.id !== target.id)
|
2017-04-17 19:42:48 -04:00
|
|
|
.map(runtimeTarget => runtimeTarget.sprite.name);
|
|
|
|
sprite.name = StringUtil.unusedName(newName, names);
|
|
|
|
}
|
|
|
|
this.emitTargetsUpdate();
|
|
|
|
} else {
|
|
|
|
throw new Error('No target with the provided id.');
|
2017-03-20 11:14:25 -04:00
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
2017-11-06 17:41:57 -05:00
|
|
|
// const targetIndexBeforeDelete = this.runtime.targets.map(t => t.id).indexOf(target.id);
|
|
|
|
// can't call target.id if target doesn't exist.
|
|
|
|
// moving below
|
2017-06-12 08:35:27 -04:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
if (target) {
|
2017-11-06 17:41:57 -05:00
|
|
|
const targetIndexBeforeDelete = this.runtime.targets.map(t => t.id).indexOf(target.id);
|
2017-04-17 19:42:48 -04:00
|
|
|
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 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) {
|
2017-06-12 08:35:27 -04:00
|
|
|
const nextTargetIndex = Math.min(this.runtime.targets.length - 1, targetIndexBeforeDelete);
|
|
|
|
this.setEditingTarget(this.runtime.targets[nextTargetIndex].id);
|
2017-04-17 19:42:48 -04:00
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
2017-04-17 19:42:48 -04:00
|
|
|
// Sprite object should be deleted by GC.
|
|
|
|
this.emitTargetsUpdate();
|
|
|
|
} else {
|
|
|
|
throw new Error('No target with the provided id.');
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-09-07 11:51:21 -04:00
|
|
|
/**
|
|
|
|
* Duplicate a sprite.
|
|
|
|
* @param {string} targetId ID of a target whose sprite to duplicate.
|
|
|
|
*/
|
|
|
|
duplicateSprite (targetId) {
|
|
|
|
const target = this.runtime.getTargetById(targetId);
|
2017-09-12 10:16:26 -04:00
|
|
|
if (!target) {
|
2017-09-07 11:51:21 -04:00
|
|
|
throw new Error('No target with the provided id.');
|
2017-09-12 10:16:26 -04:00
|
|
|
} 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.');
|
2017-09-07 11:51:21 -04:00
|
|
|
}
|
2017-09-12 10:16:26 -04:00
|
|
|
target.duplicate().then(newTarget => {
|
|
|
|
this.runtime.targets.push(newTarget);
|
|
|
|
this.setEditingTarget(newTarget.id);
|
|
|
|
});
|
2017-09-07 11:51:21 -04:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Set the audio engine for the VM/runtime
|
|
|
|
* @param {!AudioEngine} audioEngine The audio engine to attach
|
|
|
|
*/
|
|
|
|
attachAudioEngine (audioEngine) {
|
|
|
|
this.runtime.attachAudioEngine(audioEngine);
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Set the renderer for the VM/runtime
|
|
|
|
* @param {!RenderWebGL} renderer The renderer to attach
|
|
|
|
*/
|
|
|
|
attachRenderer (renderer) {
|
|
|
|
this.runtime.attachRenderer(renderer);
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Set the storage module for the VM/runtime
|
|
|
|
* @param {!ScratchStorage} storage The storage module to attach
|
|
|
|
*/
|
|
|
|
attachStorage (storage) {
|
|
|
|
this.runtime.attachStorage(storage);
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Handle a Blockly event for the flyout.
|
|
|
|
* @param {!Blockly.Event} e Any Blockly event.
|
|
|
|
*/
|
|
|
|
flyoutBlockListener (e) {
|
|
|
|
this.runtime.flyoutBlocks.blocklyListen(e, this.runtime);
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
2017-04-17 19:42:48 -04:00
|
|
|
|
2017-05-08 09:53:16 -04:00
|
|
|
/**
|
|
|
|
* Handle a Blockly event for the flyout to be passed to the monitor container.
|
|
|
|
* @param {!Blockly.Event} e Any Blockly event.
|
|
|
|
*/
|
|
|
|
monitorBlockListener (e) {
|
2017-05-10 11:22:03 -04:00
|
|
|
// 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.
|
2017-05-08 09:53:16 -04:00
|
|
|
if (['create', 'change'].indexOf(e.type) !== -1) {
|
|
|
|
this.runtime.monitorBlocks.blocklyListen(e, this.runtime);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-06-15 17:29:15 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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 (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);
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
}
|
|
|
|
|
2017-06-15 11:53:30 -04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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], ...].
|
2017-04-19 17:36:33 -04:00
|
|
|
targetList: this.runtime.targets
|
|
|
|
.filter(
|
|
|
|
// Don't report clones.
|
|
|
|
target => !target.hasOwnProperty('isOriginal') || target.isOriginal
|
|
|
|
).map(
|
|
|
|
target => target.toJSON()
|
|
|
|
),
|
2017-04-17 19:42:48 -04:00
|
|
|
// Currently editing target id.
|
|
|
|
editingTarget: this.editingTarget ? this.editingTarget.id : null
|
|
|
|
});
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Emit an Blockly/scratch-blocks compatible XML representation
|
|
|
|
* of the current editing target's blocks.
|
|
|
|
*/
|
|
|
|
emitWorkspaceUpdate () {
|
2017-07-17 12:02:48 -04:00
|
|
|
const variableMap = Object.assign({},
|
|
|
|
this.runtime.getTargetForStage().variables,
|
|
|
|
this.editingTarget.variables
|
|
|
|
);
|
|
|
|
|
2017-05-25 11:44:49 -04:00
|
|
|
const variables = Object.keys(variableMap).map(k => variableMap[k]);
|
|
|
|
|
|
|
|
const xmlString = `<xml xmlns="http://www.w3.org/1999/xhtml">
|
|
|
|
<variables>
|
|
|
|
${variables.map(v => v.toXML()).join()}
|
|
|
|
</variables>
|
|
|
|
${this.editingTarget.blocks.toXML()}
|
|
|
|
</xml>`;
|
|
|
|
|
|
|
|
this.emit('workspaceUpdate', {xml: xmlString});
|
2017-04-17 19:42:48 -04:00
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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;
|
2017-03-03 09:35:57 -05:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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) {
|
|
|
|
target.startDrag();
|
|
|
|
this.setEditingTarget(target.id);
|
|
|
|
}
|
2017-03-03 09:35:57 -05:00
|
|
|
}
|
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* 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) target.stopDrag();
|
|
|
|
}
|
2017-03-03 09:35:57 -05:00
|
|
|
|
2017-04-17 19:42:48 -04:00
|
|
|
/**
|
|
|
|
* Post/edit sprite info for the current editing target.
|
|
|
|
* @param {object} data An object with sprite info data to set.
|
|
|
|
*/
|
|
|
|
postSpriteInfo (data) {
|
|
|
|
this.editingTarget.postSpriteInfo(data);
|
|
|
|
}
|
|
|
|
}
|
2017-01-13 16:34:26 -05:00
|
|
|
|
|
|
|
module.exports = VirtualMachine;
|