/** * @fileoverview * Partial implementation of a SB3 serializer and deserializer. Parses provided * JSON and then generates all needed scratch-vm runtime structures. */ const vmPackage = require('../../package.json'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js'); /** * @typedef {object} ImportedProject * @property {Array.} targets - the imported Scratch 3.0 target objects. * @property {ImportedExtensionsInfo} extensionsInfo - the ID of each extension actually used by this project. */ /** * @typedef {object} ImportedExtensionsInfo * @property {Set.} extensionIDs - the ID of each extension actually in use by blocks in this project. * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. */ const serializeBlock = function (block) { const obj = Object.create(null); obj.id = block.id; obj.opcode = block.opcode; obj.next = block.next; obj.parent = block.parent; obj.inputs = block.inputs; obj.fields = block.fields; obj.topLevel = block.topLevel; obj.shadow = block.shadow; if (block.topLevel) { if (block.x) { obj.x = Math.round(block.x); } if (block.y) { obj.y = Math.round(block.y); } } if (block.mutation) { obj.mutation = block.mutation; } return obj; }; const serializeBlocks = function (blocks) { // TODO Array or object? const obj = Object.create(null); for (const blockID in blocks) { obj[blockID] = serializeBlock(blocks[blockID]); } return obj; }; const serializeCostume = function (costume) { const obj = Object.create(null); obj.assetId = costume.assetId; obj.name = costume.name; obj.bitmapResolution = costume.bitmapResolution; obj.dataFormat = costume.dataFormat; obj.rotationCenterX = costume.rotationCenterX; obj.rotationCenterY = costume.rotationCenterY; return obj; }; const serializeSound = function (sound) { const obj = Object.create(null); obj.assetId = sound.assetId; obj.name = sound.name; obj.dataFormat = sound.dataFormat; obj.format = sound.format; obj.rate = sound.rate; obj.sampleCount = sound.sampleCount; // TODO eventually want to get rid of this obj.md5 = sound.md5; // TODO do we need this soundID // (not to be confused with soundId which is a uid for sounds) // obj.soundID = sound.soundID; return obj; }; const serializeTarget = function (target/* , runtime*/) { const obj = Object.create(null); obj.isStage = target.isStage; // target.id === runtime.getTargetForStage().id; obj.name = target.name; obj.variables = target.variables; // This means that uids for variables will persist across saves/loads obj.blocks = serializeBlocks(target.blocks); obj.currentCostume = target.currentCostume; obj.costumes = target.costumes.map(serializeCostume); obj.sounds = target.sounds.map(serializeSound); if (!obj.isStage) { // Stage does not need the following properties obj.visible = target.visible; obj.x = target.x; obj.y = target.y; obj.size = target.size; obj.direction = target.direction; obj.draggable = target.draggable; obj.rotationStyle = target.rotationStyle; } return obj; }; /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. * @return {object} Serialized runtime instance. */ const serialize = function (runtime) { // Fetch targets const obj = Object.create(null); const flattenedOriginalTargets = JSON.parse(JSON.stringify( runtime.targets.filter(target => target.isOriginal))); obj.targets = flattenedOriginalTargets.map(t => serializeTarget(t, runtime)); // runtime.targets.filter(target => target.isOriginal); // TODO Serialize monitors // Assemble metadata const meta = Object.create(null); meta.semver = '3.0.0'; meta.vm = vmPackage.version; // Attach full user agent string to metadata if available meta.agent = null; if (typeof navigator !== 'undefined') meta.agent = navigator.userAgent; // Assemble payload and return obj.meta = meta; return obj; }; /** * Parse a single "Scratch object" and create all its in-memory VM objects. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!Runtime} runtime Runtime object to load all structures into. * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @param {JSZip} zip Sb3 file describing this project (to load assets from) * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. */ const parseScratchObject = function (object, runtime, extensions, zip) { if (!object.hasOwnProperty('name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo return Promise.resolve(null); } // Blocks container for this object. const blocks = new Blocks(); // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. const sprite = new Sprite(blocks, runtime); // Sprite/stage name from JSON. if (object.hasOwnProperty('name')) { sprite.name = object.name; } if (object.hasOwnProperty('blocks')) { for (const blockId in object.blocks) { const blockJSON = object.blocks[blockId]; blocks.createBlock(blockJSON); const dotIndex = blockJSON.opcode.indexOf('.'); if (dotIndex >= 0) { const extensionId = blockJSON.opcode.substring(0, dotIndex); extensions.extensionIDs.add(extensionId); } } // console.log(blocks); } // Costumes from JSON. const costumePromises = (object.costumes || []).map(costumeSource => { // @todo: Make sure all the relevant metadata is being pulled out. const costume = { skinId: null, name: costumeSource.name, bitmapResolution: costumeSource.bitmapResolution, rotationCenterX: costumeSource.rotationCenterX, rotationCenterY: costumeSource.rotationCenterY }; const dataFormat = costumeSource.dataFormat || (costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format 'png'; // if all else fails, guess that it might be a PNG const costumeMd5 = `${costumeSource.assetId}.${dataFormat}`; costume.md5 = costumeMd5; return deserializeCostume(costumeSource, runtime, zip) .then(() => loadCostume(costumeMd5, costume, runtime)); // Only attempt to load the costume after the deserialization // process has been completed }); // Sounds from JSON const soundPromises = (object.sounds || []).map(soundSource => { const sound = { format: soundSource.format, // fileUrl: soundSource.fileUrl, rate: soundSource.rate, sampleCount: soundSource.sampleCount, // soundID: soundSource.soundID, name: soundSource.name, md5: soundSource.md5, data: null }; return deserializeSound(soundSource, runtime, zip) .then(() => loadSound(sound, runtime)); // Only attempt to load the sound after the deserialization // process has been completed. }); // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(); // Load target properties from JSON. if (object.hasOwnProperty('variables')) { for (const j in object.variables) { const variable = object.variables[j]; const newVariable = new Variable( variable.id, variable.name, variable.type, variable.isPersistent ); newVariable.value = variable.value; target.variables[newVariable.id] = newVariable; } } if (object.hasOwnProperty('x')) { target.x = object.x; } if (object.hasOwnProperty('y')) { target.y = object.y; } if (object.hasOwnProperty('direction')) { target.direction = object.direction; } if (object.hasOwnProperty('size')) { target.size = object.size; } if (object.hasOwnProperty('visible')) { target.visible = object.visible; } if (object.hasOwnProperty('currentCostume')) { target.currentCostume = object.currentCostume; } if (object.hasOwnProperty('rotationStyle')) { target.rotationStyle = object.rotationStyle; } if (object.hasOwnProperty('isStage')) { target.isStage = object.isStage; } Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); Promise.all(soundPromises).then(sounds => { sprite.sounds = sounds; }); return Promise.all(costumePromises.concat(soundPromises)).then(() => target); }; /** * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. * TODO: parse extension info (also, design extension info storage...) * @param {object} json - JSON representation of a VM runtime. * @param {Runtime} runtime - Runtime instance * @param {JSZip} zip - Sb3 file describing this project (to load assets from) * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized */ const deserialize = function (json, runtime, zip) { const extensions = { extensionIDs: new Set(), extensionURLs: new Map() }; return Promise.all( (json.targets || []).map(target => parseScratchObject(target, runtime, extensions, zip)) ).then(targets => ({ targets, extensions })); }; module.exports = { serialize: serialize, deserialize: deserialize };