mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-24 06:52:40 -05:00
Merge pull request #552 from rschamp/feature/serialization
Add project serialization
This commit is contained in:
commit
312e15932e
13 changed files with 374 additions and 47 deletions
|
@ -43,7 +43,7 @@
|
|||
"scratch-audio": "^0.1.0-prerelease.0",
|
||||
"scratch-blocks": "^0.1.0-prerelease.0",
|
||||
"scratch-render": "^0.1.0-prerelease.0",
|
||||
"scratch-storage": "^0.0.1-prerelease.0",
|
||||
"scratch-storage": "^0.1.0",
|
||||
"script-loader": "0.7.0",
|
||||
"stats.js": "^0.17.0",
|
||||
"tap": "^10.2.0",
|
||||
|
|
|
@ -30,7 +30,8 @@ const loadCostume = function (md5ext, costume, runtime) {
|
|||
];
|
||||
|
||||
let promise = runtime.storage.load(assetType, md5).then(costumeAsset => {
|
||||
costume.url = costumeAsset.encodeDataURI();
|
||||
costume.assetId = costumeAsset.assetId;
|
||||
costume.assetType = assetType;
|
||||
return costumeAsset;
|
||||
});
|
||||
|
||||
|
|
|
@ -19,10 +19,17 @@ const loadSound = function (sound, runtime) {
|
|||
}
|
||||
const idParts = sound.md5.split('.');
|
||||
const md5 = idParts[0];
|
||||
return runtime.storage.load(runtime.storage.AssetType.Sound, md5).then(soundAsset => {
|
||||
sound.data = soundAsset.data;
|
||||
return runtime.audioEngine.decodeSound(sound).then(() => sound);
|
||||
});
|
||||
return runtime.storage.load(runtime.storage.AssetType.Sound, md5)
|
||||
.then(soundAsset => {
|
||||
sound.assetId = soundAsset.assetId;
|
||||
sound.assetType = runtime.storage.AssetType.Sound;
|
||||
return runtime.audioEngine.decodeSound(Object.assign(
|
||||
{},
|
||||
sound,
|
||||
{data: soundAsset.data}
|
||||
));
|
||||
})
|
||||
.then(() => sound);
|
||||
};
|
||||
|
||||
module.exports = loadSound;
|
||||
|
|
|
@ -53,9 +53,9 @@
|
|||
<button id="projectLoadButton">Load</button>
|
||||
<button id="createEmptyProject">New Project</button><br />
|
||||
<p>
|
||||
<input type="button" value="Export to XML" onclick="toXml()">
|
||||
<input type="button" value="Export to JSON" onclick="toJson()">
|
||||
|
||||
<input type="button" value="Import from XML" onclick="fromXml()" id="import">
|
||||
<input type="button" value="Import from JSON" onclick="fromJson()" id="import">
|
||||
<br /><br />
|
||||
<textarea id="importExport"></textarea>
|
||||
</p>
|
||||
|
@ -71,18 +71,17 @@
|
|||
<!-- Playground -->
|
||||
<script src="./playground.js"></script>
|
||||
<script>
|
||||
function toXml() {
|
||||
function toJson() {
|
||||
var output = document.getElementById('importExport');
|
||||
var xml = Blockly.Xml.workspaceToDom(Scratch.workspace);
|
||||
output.value = Blockly.Xml.domToPrettyText(xml);
|
||||
var json = window.Scratch.vm.toJSON();
|
||||
output.value = json;
|
||||
output.focus();
|
||||
output.select();
|
||||
}
|
||||
|
||||
function fromXml() {
|
||||
function fromJson() {
|
||||
var input = document.getElementById('importExport');
|
||||
var xml = Blockly.Xml.textToDom(input.value);
|
||||
Blockly.Xml.domToWorkspace(Scratch.workspace, xml);
|
||||
window.Scratch.vm.fromJSON(input.value);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
@ -11,12 +11,12 @@ const Sprite = require('../sprites/sprite');
|
|||
const Color = require('../util/color');
|
||||
const log = require('../util/log');
|
||||
const uid = require('../util/uid');
|
||||
const specMap = require('./sb2specmap');
|
||||
const specMap = require('./sb2_specmap');
|
||||
const Variable = require('../engine/variable');
|
||||
const List = require('../engine/list');
|
||||
|
||||
const loadCostume = require('./load-costume.js');
|
||||
const loadSound = require('./load-sound.js');
|
||||
const loadCostume = require('../import/load-costume.js');
|
||||
const loadSound = require('../import/load-sound.js');
|
||||
|
||||
/**
|
||||
* Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n")
|
||||
|
@ -276,14 +276,14 @@ const parseScratchObject = function (object, runtime, topLevel) {
|
|||
/**
|
||||
* Top-level handler. Parse provided JSON,
|
||||
* and process the top-level object (the stage object).
|
||||
* @param {!string} json SB2-format JSON to load.
|
||||
* @param {!object} json SB2-format JSON to load.
|
||||
* @param {!Runtime} runtime Runtime object to load all structures into.
|
||||
* @param {boolean=} optForceSprite If set, treat as sprite (Sprite2).
|
||||
* @return {?Promise} Promise that resolves to the loaded targets when ready.
|
||||
*/
|
||||
const sb2import = function (json, runtime, optForceSprite) {
|
||||
return parseScratchObject(
|
||||
JSON.parse(json),
|
||||
json,
|
||||
runtime,
|
||||
!optForceSprite
|
||||
);
|
||||
|
@ -470,4 +470,6 @@ const parseBlock = function (sb2block) {
|
|||
return activeBlock;
|
||||
};
|
||||
|
||||
module.exports = sb2import;
|
||||
module.exports = {
|
||||
deserialize: sb2import
|
||||
};
|
165
src/serialization/sb3.js
Normal file
165
src/serialization/sb3.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* @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 List = require('../engine/list');
|
||||
|
||||
const loadCostume = require('../import/load-costume.js');
|
||||
const loadSound = require('../import/load-sound.js');
|
||||
|
||||
/**
|
||||
* 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);
|
||||
obj.targets = runtime.targets;
|
||||
|
||||
// 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.
|
||||
* @return {?Target} Target created (stage or sprite).
|
||||
*/
|
||||
const parseScratchObject = function (object, runtime) {
|
||||
if (!object.hasOwnProperty('name')) {
|
||||
// Watcher/monitor - skip this object until those are implemented in VM.
|
||||
// @todo
|
||||
return;
|
||||
}
|
||||
// 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 (let blockId in object.blocks) {
|
||||
blocks.createBlock(object.blocks[blockId]);
|
||||
}
|
||||
// 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 costumeMd5 = `${costumeSource.assetId}.${costumeSource.assetType.runtimeFormat}`;
|
||||
return loadCostume(costumeMd5, costume, runtime);
|
||||
});
|
||||
// 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 loadSound(sound, runtime);
|
||||
});
|
||||
// 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 (let j = 0; j < object.variables.length; j++) {
|
||||
const variable = object.variables[j];
|
||||
target.variables[variable.name] = new Variable(
|
||||
variable.name,
|
||||
variable.value,
|
||||
variable.isPersistent
|
||||
);
|
||||
}
|
||||
}
|
||||
if (object.hasOwnProperty('lists')) {
|
||||
for (let k = 0; k < object.lists.length; k++) {
|
||||
const list = object.lists[k];
|
||||
// @todo: monitor properties.
|
||||
target.lists[list.listName] = new List(
|
||||
list.listName,
|
||||
list.contents
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deserializes the specified representation of a VM runtime and loads it into
|
||||
* the provided runtime instance.
|
||||
* @param {object} json JSON representation of a VM runtime.
|
||||
* @param {Runtime} runtime Runtime instance
|
||||
* @returns {Promise} Promise that resolves to the list of targets after the project is deserialized
|
||||
*/
|
||||
const deserialize = function (json, runtime) {
|
||||
return Promise.all((json.targets || []).map(target => parseScratchObject(target, runtime)));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
serialize: serialize,
|
||||
deserialize: deserialize
|
||||
};
|
|
@ -764,11 +764,13 @@ class RenderedTarget extends Target {
|
|||
this.dragging = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serialize sprite info, used when emitting events about the sprite
|
||||
* @returns {object} sprite data as a simple object
|
||||
* @returns {object} Sprite data as a simple object
|
||||
*/
|
||||
toJSON () {
|
||||
const costumes = this.getCostumes();
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.getName(),
|
||||
|
@ -778,12 +780,16 @@ class RenderedTarget extends Target {
|
|||
size: this.size,
|
||||
direction: this.direction,
|
||||
draggable: this.draggable,
|
||||
costume: this.getCurrentCostume(),
|
||||
costumes: this.getCostumes(),
|
||||
sounds: this.getSounds(),
|
||||
costumeCount: this.getCostumes().length,
|
||||
currentCostume: this.currentCostume,
|
||||
costume: costumes[this.currentCostume],
|
||||
costumeCount: costumes.length,
|
||||
visible: this.visible,
|
||||
rotationStyle: this.rotationStyle
|
||||
rotationStyle: this.rotationStyle,
|
||||
blocks: this.blocks._blocks,
|
||||
variables: this.variables,
|
||||
lists: this.lists,
|
||||
costumes: costumes,
|
||||
sounds: this.getSounds()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ const EventEmitter = require('events');
|
|||
|
||||
const log = require('./util/log');
|
||||
const Runtime = require('./engine/runtime');
|
||||
const sb2import = require('./import/sb2import');
|
||||
const sb2 = require('./serialization/sb2');
|
||||
const sb3 = require('./serialization/sb3');
|
||||
const StringUtil = require('./util/string-util');
|
||||
|
||||
const loadCostume = require('./import/load-costume.js');
|
||||
|
@ -145,22 +146,7 @@ class VirtualMachine extends EventEmitter {
|
|||
*/
|
||||
loadProject (json) {
|
||||
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 3.0.
|
||||
return sb2import(json, this.runtime).then(targets => {
|
||||
this.clear();
|
||||
for (let n = 0; n < targets.length; n++) {
|
||||
if (targets[n] !== null) {
|
||||
this.runtime.targets.push(targets[n]);
|
||||
targets[n].updateAllDrawableProperties();
|
||||
}
|
||||
}
|
||||
// Select the first target for editing, e.g., the first sprite.
|
||||
this.editingTarget = this.runtime.targets[1];
|
||||
|
||||
// Update the VM user's knowledge of targets and blocks on the workspace.
|
||||
this.emitTargetsUpdate();
|
||||
this.emitWorkspaceUpdate();
|
||||
this.runtime.setEditingTarget(this.editingTarget);
|
||||
});
|
||||
return this.fromJSON(json);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -180,12 +166,95 @@ class VirtualMachine extends EventEmitter {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} Project in a Scratch 3.0 JSON representation.
|
||||
*/
|
||||
saveProjectSb3 () {
|
||||
// @todo: Handle other formats, e.g., Scratch 1.4, Scratch 2.0.
|
||||
return this.toJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project as a Scratch 3.0 JSON representation.
|
||||
* @return {string} Serialized state of the runtime.
|
||||
*/
|
||||
toJSON () {
|
||||
return JSON.stringify(sb3.serialize(this.runtime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Clear the current runtime
|
||||
this.clear();
|
||||
|
||||
// Validate & parse
|
||||
if (typeof json !== 'string') {
|
||||
log.error('Failed to parse project. Non-string supplied to fromJSON.');
|
||||
return;
|
||||
}
|
||||
json = JSON.parse(json);
|
||||
if (typeof json !== 'object') {
|
||||
log.error('Failed to parse project. JSON supplied to fromJSON is not an object.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
let deserializer;
|
||||
if ((typeof json.meta !== 'undefined') && (typeof json.meta.semver !== 'undefined')) {
|
||||
deserializer = sb3;
|
||||
} else {
|
||||
deserializer = sb2;
|
||||
}
|
||||
|
||||
return deserializer.deserialize(json, this.runtime).then(targets => {
|
||||
this.clear();
|
||||
for (let n = 0; n < targets.length; n++) {
|
||||
if (targets[n] !== null) {
|
||||
this.runtime.targets.push(targets[n]);
|
||||
targets[n].updateAllDrawableProperties();
|
||||
}
|
||||
}
|
||||
// Select the first target for editing, e.g., the first sprite.
|
||||
if (this.runtime.targets.length > 1) {
|
||||
this.editingTarget = this.runtime.targets[1];
|
||||
} else {
|
||||
this.editingTarget = this.runtime.targets[0];
|
||||
}
|
||||
|
||||
// Update the VM user's knowledge of targets and blocks on the workspace.
|
||||
this.emitTargetsUpdate();
|
||||
this.emitWorkspaceUpdate();
|
||||
this.runtime.setEditingTarget(this.editingTarget);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single sprite from the "Sprite2" (i.e., SB2 sprite) format.
|
||||
* @param {string} json JSON string representing the sprite.
|
||||
* @returns {Promise} Promise that resolves after the sprite is added
|
||||
*/
|
||||
addSprite2 (json) {
|
||||
sb2import(json, this.runtime, true).then(targets => {
|
||||
// Validate & parse
|
||||
if (typeof json !== 'string') {
|
||||
log.error('Failed to parse sprite. Non-string supplied to addSprite2.');
|
||||
return;
|
||||
}
|
||||
json = JSON.parse(json);
|
||||
if (typeof json !== 'object') {
|
||||
log.error('Failed to parse sprite. JSON supplied to addSprite2 is not an object.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Select new sprite.
|
||||
return sb2.deserialize(json, this.runtime, true).then(targets => {
|
||||
this.runtime.targets.push(targets[0]);
|
||||
this.editingTarget = targets[0];
|
||||
this.editingTarget.updateAllDrawableProperties();
|
||||
|
|
1
test/fixtures/demo.json
vendored
Normal file
1
test/fixtures/demo.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -5,10 +5,10 @@ const extract = require('../fixtures/extract');
|
|||
|
||||
const renderedTarget = require('../../src/sprites/rendered-target');
|
||||
const runtime = require('../../src/engine/runtime');
|
||||
const sb2 = require('../../src/import/sb2import');
|
||||
const sb2 = require('../../src/serialization/sb2');
|
||||
|
||||
test('spec', t => {
|
||||
t.type(sb2, 'function');
|
||||
t.type(sb2.deserialize, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
|
@ -16,13 +16,15 @@ test('default', t => {
|
|||
// Get SB2 JSON (string)
|
||||
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
|
||||
const file = extract(uri);
|
||||
const json = JSON.parse(file);
|
||||
|
||||
// Create runtime instance & load SB2 into it
|
||||
const rt = new runtime();
|
||||
attachTestStorage(rt);
|
||||
sb2(file, rt).then(targets => {
|
||||
sb2.deserialize(json, rt).then(targets => {
|
||||
// Test
|
||||
t.type(file, 'string');
|
||||
t.type(json, 'object');
|
||||
t.type(rt, 'object');
|
||||
t.type(targets, 'object');
|
||||
|
||||
|
|
53
test/unit/serialization_sb2.js
Normal file
53
test/unit/serialization_sb2.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const path = require('path');
|
||||
const test = require('tap').test;
|
||||
const extract = require('../fixtures/extract');
|
||||
|
||||
const RenderedTarget = require('../../src/sprites/rendered-target');
|
||||
const Runtime = require('../../src/engine/runtime');
|
||||
const sb2 = require('../../src/serialization/sb2');
|
||||
|
||||
test('spec', t => {
|
||||
t.type(sb2, 'object');
|
||||
t.type(sb2.deserialize, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('default', t => {
|
||||
// Get SB2 JSON (string)
|
||||
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
|
||||
const file = extract(uri);
|
||||
const json = JSON.parse(file);
|
||||
|
||||
// Create runtime instance & load SB2 into it
|
||||
const rt = new Runtime();
|
||||
sb2.deserialize(json, rt).then(targets => {
|
||||
// Test
|
||||
t.type(file, 'string');
|
||||
t.type(json, 'object');
|
||||
t.type(rt, 'object');
|
||||
t.type(targets, 'object');
|
||||
|
||||
t.ok(targets[0] instanceof RenderedTarget);
|
||||
t.type(targets[0].id, 'string');
|
||||
t.type(targets[0].blocks, 'object');
|
||||
t.type(targets[0].variables, 'object');
|
||||
t.type(targets[0].lists, 'object');
|
||||
|
||||
t.equal(targets[0].isOriginal, true);
|
||||
t.equal(targets[0].currentCostume, 0);
|
||||
t.equal(targets[0].isOriginal, true);
|
||||
t.equal(targets[0].isStage, true);
|
||||
|
||||
t.ok(targets[1] instanceof RenderedTarget);
|
||||
t.type(targets[1].id, 'string');
|
||||
t.type(targets[1].blocks, 'object');
|
||||
t.type(targets[1].variables, 'object');
|
||||
t.type(targets[1].lists, 'object');
|
||||
|
||||
t.equal(targets[1].isOriginal, true);
|
||||
t.equal(targets[1].currentCostume, 0);
|
||||
t.equal(targets[1].isOriginal, true);
|
||||
t.equal(targets[1].isStage, false);
|
||||
t.end();
|
||||
});
|
||||
});
|
22
test/unit/serialization_sb3.js
Normal file
22
test/unit/serialization_sb3.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
const test = require('tap').test;
|
||||
const VirtualMachine = require('../../src/index');
|
||||
const sb3 = require('../../src/serialization/sb3');
|
||||
const demoSb3 = require('../fixtures/demo.json');
|
||||
|
||||
test('serialize', t => {
|
||||
const vm = new VirtualMachine();
|
||||
vm.fromJSON(JSON.stringify(demoSb3));
|
||||
const result = sb3.serialize(vm.runtime);
|
||||
// @todo Analyze
|
||||
t.type(JSON.stringify(result), 'string');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('deserialize', t => {
|
||||
const vm = new VirtualMachine();
|
||||
sb3.deserialize('', vm.runtime).then(targets => {
|
||||
// @todo Analyize
|
||||
t.type(targets, 'object');
|
||||
t.end();
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue