Spaces:
Running
Running
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 RenderedTarget = require('./sprites/rendered-target'); | |
const StageLayering = require('./engine/stage-layering'); | |
const Sprite = require('./sprites/sprite'); | |
const Blocks = require('./engine/blocks'); | |
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 {exportCostume} = require('./serialization/tw-costume-import-export'); | |
const Base64Util = require('./util/base64-util'); | |
const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; | |
const PM_LIBRARY_API = "https://library.penguinmod.com/"; | |
const IRGenerator = require('./compiler/irgen'); | |
const JSGenerator = require('./compiler/jsgen'); | |
const jsexecute = require('./compiler/jsexecute'); | |
const { SyntheticModule } = require('vm'); | |
const CORE_EXTENSIONS = [ | |
// 'motion', | |
// 'looks', | |
// 'sound', | |
// 'events', | |
// 'control', | |
// 'sensing', | |
// 'operators', | |
// 'variables', | |
// 'myBlocks' | |
]; | |
// Disable missing translation warnings in console | |
formatMessage.setup({ | |
missingTranslation: 'ignore' | |
}); | |
const createRuntimeService = runtime => { | |
const service = {}; | |
service._refreshExtensionPrimitives = runtime._refreshExtensionPrimitives.bind(runtime); | |
service._registerExtensionPrimitives = runtime._registerExtensionPrimitives.bind(runtime); | |
service._removeExtensionPrimitive = runtime._removeExtensionPrimitive.bind(runtime); | |
return service; | |
}; | |
/** | |
* 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', createRuntimeService(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.BLOCK_STACK_ERROR, visualReport => { | |
this.emit(Runtime.BLOCK_STACK_ERROR, 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_REMOVED, () => { | |
this.emit(Runtime.EXTENSION_REMOVED); | |
}); | |
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.RUNTIME_PAUSED, () => { | |
this.emit(Runtime.RUNTIME_PAUSED); | |
}); | |
this.runtime.on(Runtime.RUNTIME_UNPAUSED, () => { | |
this.emit(Runtime.RUNTIME_UNPAUSED); | |
}); | |
this.runtime.on(Runtime.RUNTIME_STOPPED, () => { | |
this.emit(Runtime.RUNTIME_STOPPED); | |
}); | |
this.runtime.on(Runtime.HAS_CLOUD_DATA_UPDATE, hasCloudData => { | |
this.emit(Runtime.HAS_CLOUD_DATA_UPDATE, hasCloudData); | |
}); | |
this.runtime.on(Runtime.RUNTIME_OPTIONS_CHANGED, runtimeOptions => { | |
this.emit(Runtime.RUNTIME_OPTIONS_CHANGED, runtimeOptions); | |
}); | |
this.runtime.on(Runtime.COMPILER_OPTIONS_CHANGED, compilerOptions => { | |
this.emit(Runtime.COMPILER_OPTIONS_CHANGED, compilerOptions); | |
}); | |
this.runtime.on(Runtime.FRAMERATE_CHANGED, framerate => { | |
this.emit(Runtime.FRAMERATE_CHANGED, framerate); | |
}); | |
this.runtime.on(Runtime.INTERPOLATION_CHANGED, framerate => { | |
this.emit(Runtime.INTERPOLATION_CHANGED, framerate); | |
}); | |
this.runtime.on(Runtime.BEFORE_INTERPOLATE, target => { | |
this.emit(Runtime.BEFORE_INTERPOLATE, target); | |
}); | |
this.runtime.on(Runtime.AFTER_INTERPOLATE, target => { | |
this.emit(Runtime.AFTER_INTERPOLATE, target); | |
}); | |
this.runtime.on(Runtime.STAGE_SIZE_CHANGED, (width, height) => { | |
this.emit(Runtime.STAGE_SIZE_CHANGED, width, height); | |
}); | |
this.runtime.on(Runtime.COMPILE_ERROR, (target, error) => { | |
this.emit(Runtime.COMPILE_ERROR, target, error); | |
}); | |
this.runtime.on(Runtime.TURBO_MODE_OFF, () => { | |
this.emit(Runtime.TURBO_MODE_OFF); | |
}); | |
this.runtime.on(Runtime.TURBO_MODE_ON, () => { | |
this.emit(Runtime.TURBO_MODE_ON); | |
}); | |
this.extensionManager = new ExtensionManager(this); | |
this.securityManager = this.extensionManager.securityManager; | |
this.runtime.extensionManager = this.extensionManager; | |
this.runtime.vm = this; | |
// 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); | |
this.addListener('workspaceUpdate', () => { | |
this.extensionManager.refreshDynamicCategorys(); | |
}); | |
/** | |
* Export some internal classes for extensions. | |
*/ | |
this.exports = { | |
Sprite, | |
RenderedTarget, | |
JSZip, | |
JSGenerator, | |
IRGenerator, | |
jsexecute, | |
loadCostume, | |
loadSound, | |
Blocks, | |
StageLayering, | |
Variable, | |
Thread: require('./engine/thread.js'), | |
execute: require('./engine/execute.js') | |
}; | |
} | |
/** | |
* Start running the VM - do this before anything else. | |
*/ | |
start () { | |
this.runtime.start(); | |
} | |
/** | |
* tw: Stop running the VM | |
* Note: This only stops the loop. It will not stop any threads the next time the VM starts | |
*/ | |
stop () { | |
this.runtime.stop(); | |
} | |
/** | |
* "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); | |
} | |
setFramerate (framerate) { | |
this.runtime.setFramerate(framerate); | |
} | |
setInterpolation (interpolationEnabled) { | |
this.runtime.setInterpolation(interpolationEnabled); | |
} | |
setRuntimeOptions (runtimeOptions) { | |
this.runtime.setRuntimeOptions(runtimeOptions); | |
} | |
setCompilerOptions (compilerOptions) { | |
this.runtime.setCompilerOptions(compilerOptions); | |
} | |
setStageSize (width, height) { | |
this.runtime.setStageSize(width, height); | |
} | |
setInEditor (inEditor) { | |
this.runtime.setInEditor(inEditor); | |
} | |
convertToPackagedRuntime () { | |
this.runtime.convertToPackagedRuntime(); | |
} | |
addAddonBlock (options) { | |
this.runtime.addAddonBlock(options); | |
} | |
getAddonBlock (procedureCode) { | |
return this.runtime.getAddonBlock(procedureCode); | |
} | |
storeProjectOptions () { | |
this.runtime.storeProjectOptions(); | |
if (this.editingTarget.isStage) { | |
this.emitWorkspaceUpdate(); | |
} | |
} | |
enableDebug () { | |
this.runtime.enableDebug(); | |
return 'enabled debug mode'; | |
} | |
/** | |
* 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); | |
} | |
isSB2(json) { | |
return Array.isArray(json.children) && !Array.isArray(json.targets); | |
} | |
/** | |
* 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) { | |
return new Promise(async (resolve, reject) => { | |
try { | |
const arr = new Uint8Array(input); | |
const tag = [...arr.slice(0, 7)] | |
.map(char => String.fromCharCode(char)) | |
.join(''); | |
if (tag === 'Scratch') { | |
const { SB1File } = require('scratch-sb1-converter'); | |
const sb1 = new SB1File(input); | |
const json = sb1.json; | |
json.projectVersion = 2; | |
return resolve([json, sb1.zip]); | |
} | |
// if it isnt a zip, maby its the project.json in ArrayBuffer form | |
if (tag.slice(0, 2) !== 'PK') { | |
const decoder = new TextDecoder('UTF-8'); | |
input = decoder.decode(input); | |
} | |
if (typeof input === 'string') | |
input = JSON.parse(input); | |
// generic objects return [object Object] on stringify | |
if (input.toString() === '[object Object]') { | |
input.projectVersion = this.isSB2(input) ? 2 : 3; | |
return resolve([input, null]); | |
} | |
const zip = await JSZip.loadAsync(input); | |
const proj = zip.file('project.json'); | |
if (!proj) return reject('No project.json file inside the given project'); | |
const json = JSON.parse(await proj.async('string')); | |
delete json.meta; | |
json.projectVersion = this.isSB2(json) ? 2 : 3; | |
return resolve([json, zip]); | |
} catch (err) { | |
reject(err.toString()); | |
} | |
}) | |
.then(validatedInput => this.deserializeProject(validatedInput[0], validatedInput[1])) | |
.then(() => this.runtime.emitProjectLoaded()) | |
.catch(error => { | |
console.error(error); | |
// Intentionally rejecting here (want errors to be handled by caller) | |
if (error.hasOwnProperty('validationError')) { | |
return Promise.reject(JSON.stringify(error, null, 4)); | |
} | |
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 {JSZip} JSZip zip object representing the sb3. | |
*/ | |
_saveProjectZip () { | |
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(this.serializeAssets(), zip); | |
// Use a fixed modification date for the files in the zip instead of letting JSZip use the | |
// current time to avoid a very small metadata leak and make zipping deterministic. The magic | |
// number is from the first TurboWarp/scratch-vm commit after forking | |
const date = new Date(1591657163000); | |
for (const file of Object.values(zip.files)) { | |
file.date = date; | |
} | |
return zip; | |
} | |
/** | |
* @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'blob' for Scratch compatibility. | |
* @returns {Promise<unknown>} Compressed sb3 file in a type determined by the type argument. | |
*/ | |
saveProjectSb3 (type) { | |
return this._saveProjectZip().generateAsync({ | |
type: type || 'blob', | |
mimeType: 'application/x.scratch.sb3', | |
compression: 'DEFLATE' | |
}); | |
} | |
/** | |
* @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'arraybuffer'. | |
* @returns {StreamHelper} JSZip StreamHelper object generating the compressed sb3. | |
* See: https://stuk.github.io/jszip/documentation/api_streamhelper.html | |
*/ | |
saveProjectSb3Stream (type) { | |
return this._saveProjectZip().generateInternalStream({ | |
type: type || 'arraybuffer', | |
mimeType: 'application/x.scratch.sb3', | |
compression: 'DEFLATE' | |
}); | |
} | |
/** | |
* tw: Serialize the project into a map of files without actually zipping the project. | |
* The buffers returned are the exact same ones used internally, not copies. Avoid directly | |
* manipulating them (except project.json, which is created by this function). | |
* @returns {Record<string, Uint8Array>} Map of file name to the raw data for that file. | |
*/ | |
saveProjectSb3DontZip () { | |
const projectJson = this.toJSON(); | |
const files = { | |
'project.json': new _TextEncoder().encode(projectJson) | |
}; | |
for (const fileDesc of this.serializeAssets()) { | |
files[fileDesc.fileName] = fileDesc.fileContent; | |
} | |
return files; | |
} | |
/** | |
* @type {Array<object>} Array of all assets currently in the runtime | |
*/ | |
get assets () { | |
const costumesAndSounds = this.runtime.targets.reduce((acc, target) => ( | |
acc | |
.concat(target.sprite.sounds.map(sound => sound.asset)) | |
.concat(target.sprite.costumes.map(costume => costume.asset)) | |
), []); | |
const fonts = this.runtime.fontManager.serializeAssets(); | |
return [ | |
...costumesAndSounds, | |
...fonts | |
]; | |
} | |
/** | |
* @param {string} targetId Optional ID of target to export | |
* @returns {Array<{fileName: string; fileContent: Uint8Array;}} list of file descs | |
*/ | |
serializeAssets(targetId) { | |
const costumeDescs = serializeCostumes(this.runtime, targetId); | |
const soundDescs = serializeSounds(this.runtime, targetId); | |
const fontDescs = this.runtime.fontManager.serializeAssets().map(asset => ({ | |
fileName: `${asset.assetId}.${asset.dataFormat}`, | |
fileContent: asset.data | |
})); | |
return [ | |
...costumeDescs, | |
...soundDescs, | |
...fontDescs | |
]; | |
} | |
_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 spriteJson = this.toJSON(targetId); | |
const zip = new JSZip(); | |
zip.file('sprite.json', spriteJson); | |
this._addFileDescsToZip(this.serializeAssets(targetId), 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 | |
* @param {*} serializationOptions Options to pass to the serializer | |
* @return {string} Serialized state of the runtime. | |
*/ | |
toJSON (optTargetId, serializationOptions) { | |
const sb3 = require('./serialization/sb3'); | |
return StringUtil.stringify(sb3.serialize(this.runtime, optTargetId, serializationOptions)); | |
} | |
// 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'); | |
// eslint-disable-next-line no-invalid-this | |
return sb3.deserialize(projectJSON, runtime, zip, false, this); | |
} | |
return Promise.reject('Unable to verify Scratch Project version.'); | |
}; | |
return deserializePromise() | |
.then(({targets, extensions}) => { | |
if (typeof performance !== 'undefined') { | |
performance.mark('scratch-vm-deserialize-end'); | |
try { | |
performance.measure('scratch-vm-deserialize', | |
'scratch-vm-deserialize-start', 'scratch-vm-deserialize-end'); | |
} catch (e) { | |
// performance.measure() will throw an error if the start deserialize | |
// marker was removed from memory before we finished deserializing | |
// the project. We've seen this happen a couple times when loading | |
// very large projects. | |
log.error(e); | |
} | |
} | |
return this.installTargets(targets, extensions, true); | |
}); | |
} | |
/** | |
* @param {string[]} extensionIDs The IDs of the extensions | |
* @param {Map<string, string>} extensionURLs A map of extension ID to URL | |
*/ | |
async _loadExtensions (extensionIDs, extensionURLs = new Map()) { | |
const extensionPromises = []; | |
for (const extensionID of extensionIDs) { | |
const url = extensionURLs.get(extensionID); | |
if (this.extensionManager.isExtensionLoaded(extensionID)) { | |
// Already loaded | |
} else if (url) { | |
// extension url | |
if (await this.securityManager.canLoadExtensionFromProject(url)) { | |
extensionPromises.push(this.extensionManager.loadExtensionURL(url)); | |
} else { | |
throw new Error(`Permission to load extension denied: ${extensionID}`); | |
} | |
} else if (this.extensionManager.isBuiltinExtension(extensionID)) { | |
// Builtin extension | |
this.extensionManager.loadExtensionIdSync(extensionID); | |
} else { | |
throw new Error(`Unknown extension: ${extensionID}`); | |
} | |
} | |
return Promise.all(extensionPromises); | |
} | |
/** | |
* 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 | |
*/ | |
async installTargets (targets, extensions, wholeProject) { | |
await this.extensionManager.allAsyncExtensionsLoaded(); | |
targets = targets.filter(target => !!target); | |
return this._loadExtensions(extensions.extensionIDs, extensions.extensionURLs).then(() => { | |
for (const extension of extensions.extensionIDs) { | |
if (`ext_${extension}` in this.runtime) { | |
if ((typeof this.runtime[`ext_${extension}`].deserialize === 'function') && | |
extensions.extensionData[extension]) { | |
this.runtime[`ext_${extension}`].deserialize(extensions.extensionData[extension]); | |
} | |
} | |
} | |
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(); | |
} | |
if (wholeProject) { | |
this.runtime.parseProjectOptions(); | |
} | |
// 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 | |
// 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 {number} 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) { | |
if (costumeObject.fromPenguinModLibrary === true) { | |
return new Promise((resolve, reject) => { | |
fetch(`${PM_LIBRARY_API}files/${costumeObject.libraryId}`) | |
.then((r) => r.arrayBuffer()) | |
.then((arrayBuffer) => { | |
const dataFormat = costumeObject.dataFormat; | |
const storage = this.runtime.storage; | |
const asset = new storage.Asset( | |
storage.AssetType[dataFormat === 'svg' ? "ImageVector" : "ImageBitmap"], | |
null, | |
storage.DataFormat[dataFormat.toUpperCase()], | |
new Uint8Array(arrayBuffer), | |
true | |
); | |
const newCostumeObject = { | |
md5: asset.assetId + '.' + asset.dataFormat, | |
asset: asset, | |
name: costumeObject.name | |
} | |
loadCostume(newCostumeObject.md5, newCostumeObject, this.runtime, optVersion).then(costumeAsset => { | |
target.addCostume(newCostumeObject); | |
target.setCostume( | |
target.getCostumes().length - 1 | |
); | |
this.runtime.emitProjectChanged(); | |
resolve(costumeAsset, newCostumeObject); | |
}) | |
}).catch(reject); | |
}); | |
} | |
return loadCostume(md5ext, costumeObject, this.runtime, optVersion).then(costumeObject => { | |
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; | |
} | |
/** | |
* Pause running scripts | |
*/ | |
pause() { | |
this.runtime.pause(); | |
} | |
/** | |
* Unpause running scripts | |
*/ | |
play() { | |
this.runtime.play(); | |
} | |
/** | |
* 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) { | |
if (soundObject.fromPenguinModLibrary === true) { | |
return new Promise((resolve, reject) => { | |
fetch(`${PM_LIBRARY_API}files/${soundObject.libraryId}`) | |
.then((r) => r.arrayBuffer()) | |
.then((arrayBuffer) => { | |
const storage = this.runtime.storage; | |
const asset = new storage.Asset( | |
storage.AssetType.Sound, | |
null, | |
storage.DataFormat.MP3, | |
new Uint8Array(arrayBuffer), | |
true | |
); | |
const newSoundObject = { | |
md5: asset.assetId + '.' + asset.dataFormat, | |
asset: asset, | |
name: soundObject.name | |
} | |
loadSound(newSoundObject, this.runtime, target.sprite.soundBank).then(soundAsset => { | |
target.addSound(newSoundObject); | |
this.emitTargetsUpdate(); | |
resolve(soundAsset, newSoundObject); | |
}); | |
}).catch(reject); | |
}); | |
} | |
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 Promise.reject(new Error(`No target with ID: ${optTargetId}`)); | |
} | |
/** | |
* 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; | |
} | |
/** | |
* TW: Get the raw binary data to use when exporting a costume to the user's local file system. | |
* @param {Costume} costumeObject scratch-vm costume object | |
* @returns {Uint8Array} | |
*/ | |
getExportedCostume (costumeObject) { | |
return exportCostume(costumeObject); | |
} | |
/** | |
* TW: Get a base64 string to use when exporting a costume to the user's local file system. | |
* @param {Costume} costumeObject scratch-vm costume object | |
* @returns {string} base64 string. Not a data: URI. | |
*/ | |
getExportedCostumeBase64 (costumeObject) { | |
const binaryData = this.getExportedCostume(costumeObject); | |
return Base64Util.uint8ArrayToBase64(binaryData); | |
} | |
/** | |
* 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) { | |
return this._updateBitmap( | |
this.editingTarget.getCostumes()[costumeIndex], | |
bitmap, | |
rotationCenterX, | |
rotationCenterY, | |
bitmapResolution | |
); | |
} | |
_updateBitmap (costume, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) { | |
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) { | |
return this._updateSvg( | |
this.editingTarget.getCostumes()[costumeIndex], | |
svg, | |
rotationCenterX, | |
rotationCenterY | |
); | |
} | |
_updateSvg (costume, svg, rotationCenterX, rotationCenterY) { | |
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) { | |
if (backdropObject.fromPenguinModLibrary === true) { | |
return new Promise((resolve, reject) => { | |
fetch(`${PM_LIBRARY_API}files/${backdropObject.libraryId}`) | |
.then((r) => r.arrayBuffer()) | |
.then((arrayBuffer) => { | |
const dataFormat = backdropObject.dataFormat; | |
const storage = this.runtime.storage; | |
const asset = new storage.Asset( | |
storage.AssetType[dataFormat === 'svg' ? "ImageVector" : "ImageBitmap"], | |
null, | |
storage.DataFormat[dataFormat.toUpperCase()], | |
new Uint8Array(arrayBuffer), | |
true | |
); | |
const newCostumeObject = { | |
md5: asset.assetId + '.' + asset.dataFormat, | |
asset: asset, | |
name: backdropObject.name | |
} | |
loadCostume(newCostumeObject.md5, newCostumeObject, this.runtime).then(costumeAsset => { | |
const stage = this.runtime.getTargetForStage(); | |
stage.addCostume(newCostumeObject); | |
stage.setCostume(stage.getCostumes().length - 1); | |
this.runtime.emitProjectChanged(); | |
resolve(costumeAsset, newCostumeObject); | |
}) | |
}).catch(reject); | |
}); | |
} | |
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; | |
if (oldName === newUnusedName) { | |
return; | |
} | |
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.'); | |
} | |
/** | |
* pm: Clone of deleteSprite, used if an addon or script replaces the original deleteSprite. | |
* @param {string} targetId ID of a target whose sprite to delete. | |
* @return {Function} Returns a function to restore the sprite that was deleted | |
*/ | |
deleteSpriteInternal(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}}); | |
} | |
this.emit('LOCALE_CHANGED', locale); | |
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); | |
} | |
} | |
/** | |
* 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); | |
} | |
} | |
/** | |
* @param {Block[]} blockObjects | |
* @returns {object} | |
*/ | |
exportStandaloneBlocks (blockObjects) { | |
const sb3 = require('./serialization/sb3'); | |
const serialized = sb3.serializeStandaloneBlocks(blockObjects, this.runtime); | |
return serialized; | |
} | |
/** | |
* Called when blocks are dragged from one sprite to another. Adds the blocks to the | |
* workspace of the given target. | |
* @param {!Array<object>} blocks Blocks to add. | |
* @param {!string} targetId Id of target to add blocks to. | |
* @param {?string} optFromTargetId Optional target id indicating that blocks are being | |
* shared from that target. This is needed for resolving any potential variable conflicts. | |
* @return {!Promise} Promise that resolves when the extensions and blocks have been added. | |
*/ | |
shareBlocksToTarget (blocks, targetId, optFromTargetId) { | |
const sb3 = require('./serialization/sb3'); | |
const {blocks: copiedBlocks, extensionURLs} = sb3.deserializeStandaloneBlocks(blocks); | |
newBlockIds(copiedBlocks); | |
const target = this.runtime.getTargetById(targetId); | |
if (optFromTargetId) { | |
// If the blocks are being shared from another target, | |
// resolve any possible variable conflicts that may arise. | |
const fromTarget = this.runtime.getTargetById(optFromTargetId); | |
fromTarget.resolveVariableSharingConflictsWithTarget(copiedBlocks, target); | |
} | |
// Create a unique set of extensionIds that are not yet loaded | |
const extensionIDs = new Set(copiedBlocks | |
.map(b => sb3.getExtensionIdForOpcode(b.opcode)) | |
.filter(id => !!id) // Remove ids that do not exist | |
.filter(id => !this.extensionManager.isExtensionLoaded(id)) // and remove loaded extensions | |
); | |
return this._loadExtensions(extensionIDs, extensionURLs).then(() => { | |
copiedBlocks.forEach(block => { | |
target.blocks.createBlock(block); | |
}); | |
target.blocks.updateTargetSpecificBlocks(target.isStage); | |
}); | |
} | |
/** | |
* Called when costumes are dragged from editing target to another target. | |
* Sets the newly added costume as the current costume. | |
* @param {!number} costumeIndex Index of the costume of the editing target to share. | |
* @param {!string} targetId Id of target to add the costume. | |
* @return {Promise} Promise that resolves when the new costume has been loaded. | |
*/ | |
shareCostumeToTarget (costumeIndex, targetId) { | |
const originalCostume = this.editingTarget.getCostumes()[costumeIndex]; | |
const clone = Object.assign({}, originalCostume); | |
const md5ext = `${clone.assetId}.${clone.dataFormat}`; | |
return loadCostume(md5ext, clone, this.runtime).then(() => { | |
const target = this.runtime.getTargetById(targetId); | |
if (target) { | |
target.addCostume(clone); | |
target.setCostume( | |
target.getCostumes().length - 1 | |
); | |
} | |
}); | |
} | |
/** | |
* Called when sounds are dragged from editing target to another target. | |
* @param {!number} soundIndex Index of the sound of the editing target to share. | |
* @param {!string} targetId Id of target to add the sound. | |
* @return {Promise} Promise that resolves when the new sound has been loaded. | |
*/ | |
shareSoundToTarget (soundIndex, targetId) { | |
const originalSound = this.editingTarget.getSounds()[soundIndex]; | |
const clone = Object.assign({}, originalSound); | |
const target = this.runtime.getTargetById(targetId); | |
return loadSound(clone, this.runtime, target.sprite.soundBank).then(() => { | |
if (target) { | |
target.addSound(clone); | |
this.emitTargetsUpdate(); | |
} | |
}); | |
} | |
/** | |
* 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); | |
this.emitTargetsUpdate(false /* Don't emit project change */); | |
} | |
} | |
/** | |
* Emit metadata about available targets. | |
* An editor UI could use this to display a list of targets and show | |
* the currently editing one. | |
* @param {bool} triggerProjectChange If true, also emit a project changed event. | |
* Disabled selectively by updates that don't affect project serialization. | |
* Defaults to true. | |
*/ | |
emitTargetsUpdate (triggerProjectChange) { | |
if (typeof triggerProjectChange === 'undefined') triggerProjectChange = true; | |
let lazyTargetList; | |
const getTargetListLazily = () => { | |
if (!lazyTargetList) { | |
lazyTargetList = this.runtime.targets | |
.filter( | |
// Don't report clones. | |
target => !target.hasOwnProperty('isOriginal') || target.isOriginal | |
).map( | |
target => target.toJSON() | |
); | |
} | |
return lazyTargetList; | |
}; | |
this.emit('targetsUpdate', { | |
// [[target id, human readable target name], ...]. | |
get targetList () { | |
return getTargetListLazily(); | |
}, | |
// Currently editing target id. | |
editingTarget: this.editingTarget ? this.editingTarget.id : null | |
}); | |
if (triggerProjectChange) { | |
this.runtime.emitProjectChanged(); | |
} | |
} | |
/** | |
* 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 globalVarMap = Object.assign({}, this.runtime.getTargetForStage().variables); | |
const localVarMap = this.editingTarget.isStage ? | |
Object.create(null) : | |
Object.assign({}, this.editingTarget.variables); | |
const globalVariables = Object.keys(globalVarMap).map(k => globalVarMap[k]); | |
const localVariables = Object.keys(localVarMap).map(k => localVarMap[k]); | |
const workspaceComments = Object.keys(this.editingTarget.comments) | |
.map(k => this.editingTarget.comments[k]) | |
.filter(c => c.blockId === null); | |
const xmlString = `<xml xmlns="http://www.w3.org/1999/xhtml"> | |
<variables> | |
${globalVariables.map(v => v.toXML()).join()} | |
${localVariables.map(v => v.toXML(true)).join()} | |
</variables> | |
${workspaceComments.map(c => c.toXML()).join()} | |
${this.editingTarget.blocks.toXML(this.editingTarget.comments)} | |
</xml>`; | |
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; | |
} | |
/** | |
* Reorder target by index. Return whether a change was made. | |
* @param {!string} targetIndex Index of the target. | |
* @param {!number} newIndex index that the target should be moved to. | |
* @returns {boolean} Whether a target was reordered. | |
*/ | |
reorderTarget (targetIndex, newIndex) { | |
let targets = this.runtime.targets; | |
targetIndex = MathUtil.clamp(targetIndex, 0, targets.length - 1); | |
newIndex = MathUtil.clamp(newIndex, 0, targets.length - 1); | |
if (targetIndex === newIndex) return false; | |
const target = targets[targetIndex]; | |
targets = targets.slice(0, targetIndex).concat(targets.slice(targetIndex + 1)); | |
targets.splice(newIndex, 0, target); | |
this.runtime.targets = targets; | |
this.emitTargetsUpdate(); | |
return true; | |
} | |
/** | |
* Reorder the costumes of a target if it exists. Return whether it succeeded. | |
* @param {!string} targetId ID of the target which owns the costumes. | |
* @param {!number} costumeIndex index of the costume to move. | |
* @param {!number} newIndex index that the costume should be moved to. | |
* @returns {boolean} Whether a costume was reordered. | |
*/ | |
reorderCostume (targetId, costumeIndex, newIndex) { | |
const target = this.runtime.getTargetById(targetId); | |
if (target) { | |
const reorderSuccessful = target.reorderCostume(costumeIndex, newIndex); | |
if (reorderSuccessful) { | |
this.runtime.emitProjectChanged(); | |
} | |
return reorderSuccessful; | |
} | |
return false; | |
} | |
/** | |
* Reorder the sounds of a target if it exists. Return whether it occured. | |
* @param {!string} targetId ID of the target which owns the sounds. | |
* @param {!number} soundIndex index of the sound to move. | |
* @param {!number} newIndex index that the sound should be moved to. | |
* @returns {boolean} Whether a sound was reordered. | |
*/ | |
reorderSound (targetId, soundIndex, newIndex) { | |
const target = this.runtime.getTargetById(targetId); | |
if (target) { | |
const reorderSuccessful = target.reorderSound(soundIndex, newIndex); | |
if (reorderSuccessful) { | |
this.runtime.emitProjectChanged(); | |
} | |
return reorderSuccessful; | |
} | |
return false; | |
} | |
/** | |
* 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); | |
} | |
// Post sprite info means the gui has changed something about a sprite, | |
// either through the sprite info pane fields (e.g. direction, size) or | |
// through dragging a sprite on the stage | |
// Emit a project changed event. | |
this.runtime.emitProjectChanged(); | |
} | |
/** | |
* Set a target's variable's value. Return whether it succeeded. | |
* @param {!string} targetId ID of the target which owns the variable. | |
* @param {!string} variableId ID of the variable to set. | |
* @param {!*} value The new value of that variable. | |
* @returns {boolean} whether the target and variable were found and updated. | |
*/ | |
setVariableValue (targetId, variableId, value) { | |
const target = this.runtime.getTargetById(targetId); | |
if (target) { | |
const variable = target.lookupVariableById(variableId); | |
if (variable) { | |
variable.value = value; | |
if (variable.isCloud) { | |
this.runtime.ioDevices.cloud.requestUpdateVariable(variable.name, variable.value); | |
} | |
return true; | |
} | |
} | |
return false; | |
} | |
/** | |
* Get a target's variable's value. Return null if the target or variable does not exist. | |
* @param {!string} targetId ID of the target which owns the variable. | |
* @param {!string} variableId ID of the variable to set. | |
* @returns {?*} The value of the variable, or null if it could not be looked up. | |
*/ | |
getVariableValue (targetId, variableId) { | |
const target = this.runtime.getTargetById(targetId); | |
if (target) { | |
const variable = target.lookupVariableById(variableId); | |
if (variable) { | |
return variable.value; | |
} | |
} | |
return null; | |
} | |
/** | |
* Allow VM consumer to configure the ScratchLink socket creator. | |
* @param {Function} factory The custom ScratchLink socket factory. | |
*/ | |
configureScratchLinkSocketFactory (factory) { | |
this.runtime.configureScratchLinkSocketFactory(factory); | |
} | |
} | |
module.exports = VirtualMachine; | |