diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 16db421fe..9a37e5c05 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -12,6 +12,7 @@ const Comment = require('../engine/comment'); const StageLayering = require('../engine/stage-layering'); const log = require('../util/log'); const uid = require('../util/uid'); +const MathUtil = require('../util/math-util'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -437,6 +438,7 @@ const serializeTarget = function (target, extensions) { obj.costumes = target.costumes.map(serializeCostume); obj.sounds = target.sounds.map(serializeSound); if (target.hasOwnProperty('volume')) obj.volume = target.volume; + if (target.hasOwnProperty('layerOrder')) obj.layerOrder = target.layerOrder; if (obj.isStage) { // Only the stage should have these properties if (target.hasOwnProperty('tempo')) obj.tempo = target.tempo; if (target.hasOwnProperty('videoTransparency')) obj.videoTransparency = target.videoTransparency; @@ -458,6 +460,11 @@ const serializeTarget = function (target, extensions) { return obj; }; +const getSimplifiedLayerOrdering = function (targets) { + const layerOrders = targets.map(t => t.getLayerOrder()); + return MathUtil.reducedSortOrdering(layerOrders); +}; + /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. @@ -469,9 +476,20 @@ const serialize = function (runtime, targetId) { const obj = Object.create(null); // Create extension set to hold extension ids found while serializing targets const extensions = new Set(); - const flattenedOriginalTargets = JSON.parse(JSON.stringify(targetId ? + + const originalTargetsToSerialize = targetId ? [runtime.getTargetById(targetId)] : - runtime.targets.filter(target => target.isOriginal))); + runtime.targets.filter(target => target.isOriginal); + + const layerOrdering = getSimplifiedLayerOrdering(originalTargetsToSerialize); + + const flattenedOriginalTargets = JSON.parse(JSON.stringify(originalTargetsToSerialize)); + + if (runtime.renderer) { + flattenedOriginalTargets.forEach((t, index) => { + t.layerOrder = layerOrdering[index]; + }); + } const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions)); @@ -930,6 +948,12 @@ const parseScratchObject = function (object, runtime, extensions, zip) { if (object.hasOwnProperty('isStage')) { target.isStage = object.isStage; } + if (object.hasOwnProperty('targetPaneOrder')) { + // Temporarily store the 'targetPaneOrder' property + // so that we can correctly order sprites in the target pane. + // This will be deleted after we are done parsing and ordering the targets list. + target.targetPaneOrder = object.targetPaneOrder; + } Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); @@ -952,10 +976,28 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { extensionIDs: new Set(), extensionURLs: new Map() }; + + // First keep track of the current target order in the json, + // then sort by the layer order property before parsing the targets + // so that their corresponding render drawables can be created in + // their layer order (e.g. back to front) + const targetObjects = ((isSingleSprite ? [json] : json.targets) || []) + .map((t, i) => Object.assign(t, {targetPaneOrder: i})) + .sort((a, b) => a.layerOrder - b.layerOrder); + return Promise.all( - ((isSingleSprite ? [json] : json.targets) || []).map(target => + targetObjects.map(target => parseScratchObject(target, runtime, extensions, zip)) ) + .then(targets => targets // Re-sort targets back into original sprite-pane ordering + .sort((a, b) => a.targetPaneOrder - b.targetPaneOrder) + .map(t => { + // Delete the temporary properties used for + // sprite pane ordering and stage layer ordering + delete t.targetPaneOrder; + delete t.layerOrder; + return t; + })) .then(targets => ({ targets, extensions diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index eb996786b..93ee08661 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -879,6 +879,13 @@ class RenderedTarget extends Target { return false; } + getLayerOrder () { + if (this.renderer) { + return this.renderer.getDrawableOrder(this.drawableID); + } + return null; + } + /** * Move to the front layer. */ diff --git a/src/util/math-util.js b/src/util/math-util.js index 542f1bd42..c5b5704e8 100644 --- a/src/util/math-util.js +++ b/src/util/math-util.js @@ -63,6 +63,20 @@ class MathUtil { return parseFloat(Math.tan((Math.PI * angle) / 180).toFixed(10)); } } + + /** + * Given an array of unique numbers, + * returns a reduced array such that each element of the reduced array + * represents the position of that element in a sorted version of the + * original array. + * E.g. [5, 19. 13, 1] => [1, 3, 2, 0] + * @param {Array} elts The elements to sort and reduce + * @return {Array} The array of reduced orderings + */ + static reducedSortOrdering (elts) { + const sorted = elts.slice(0).sort((a, b) => a - b); + return elts.map(e => sorted.indexOf(e)); + } } module.exports = MathUtil; diff --git a/test/fixtures/fake-renderer.js b/test/fixtures/fake-renderer.js index 341300fba..587de5526 100644 --- a/test/fixtures/fake-renderer.js +++ b/test/fixtures/fake-renderer.js @@ -34,10 +34,6 @@ FakeRenderer.prototype.isTouchingColor = function (d, c) { // eslint-disable-lin return true; }; -FakeRenderer.prototype.setDrawableOrder = function (d, l, optA, optB) { // eslint-disable-line no-unused-vars - return true; -}; - FakeRenderer.prototype.getBounds = function (d) { // eslint-disable-line no-unused-vars return {left: this.x, right: this.x, top: this.y, bottom: this.y}; }; @@ -55,6 +51,10 @@ FakeRenderer.prototype.setDrawableOrder = function (d, a, optG, optA, optB) { // return this.order; }; +FakeRenderer.prototype.getDrawableOrder = function (d) { // eslint-disable-line no-unused-vars + return 'stub'; +}; + FakeRenderer.prototype.pick = function (x, y, a, b, c) { // eslint-disable-line no-unused-vars return c[0]; }; diff --git a/test/unit/serialization_sb3.js b/test/unit/serialization_sb3.js index 12abbba97..40cc8e23b 100644 --- a/test/unit/serialization_sb3.js +++ b/test/unit/serialization_sb3.js @@ -7,6 +7,7 @@ const exampleProjectPath = path.resolve(__dirname, '../fixtures/clone-cleanup.sb const commentsSB2ProjectPath = path.resolve(__dirname, '../fixtures/comments.sb2'); const commentsSB3ProjectPath = path.resolve(__dirname, '../fixtures/comments.sb3'); const commentsSB3NoDupeIds = path.resolve(__dirname, '../fixtures/comments_no_duplicate_id_serialization.sb3'); +const FakeRenderer = require('../fixtures/fake-renderer'); test('serialize', t => { const vm = new VirtualMachine(); @@ -142,3 +143,42 @@ test('deserialize sb3 project with comments - no duplicate id serialization', t t.end(); }); }); + +test('serialize sb3 preserves sprite layer order', t => { + const vm = new VirtualMachine(); + vm.attachRenderer(new FakeRenderer()); + vm.loadProject(readFileToBuffer(path.resolve(__dirname, '../fixtures/ordering.sb2'))) + .then(() => { + // Target get layer order needs a renderer, + // fake the numbers we would get back from the + // renderer in order to test that they are serialized + // correctly + vm.runtime.targets[0].getLayerOrder = () => 0; + vm.runtime.targets[1].getLayerOrder = () => 20; + vm.runtime.targets[2].getLayerOrder = () => 10; + vm.runtime.targets[3].getLayerOrder = () => 30; + + const result = sb3.serialize(vm.runtime); + + t.type(JSON.stringify(result), 'string'); + t.type(result.targets, 'object'); + t.equal(Array.isArray(result.targets), true); + t.equal(result.targets.length, 4); + + // First check that the sprites are ordered correctly (as they would + // appear in the target pane) + t.equal(result.targets[0].name, 'Stage'); + t.equal(result.targets[1].name, 'First'); + t.equal(result.targets[2].name, 'Second'); + t.equal(result.targets[3].name, 'Third'); + + // Check that they are in the correct layer order (as they would render + // back to front on the stage) + t.equal(result.targets[0].layerOrder, 0); + t.equal(result.targets[1].layerOrder, 2); + t.equal(result.targets[2].layerOrder, 1); + t.equal(result.targets[3].layerOrder, 3); + + t.end(); + }); +}); diff --git a/test/unit/sprites_rendered-target.js b/test/unit/sprites_rendered-target.js index b9b3b9381..650887307 100644 --- a/test/unit/sprites_rendered-target.js +++ b/test/unit/sprites_rendered-target.js @@ -326,6 +326,23 @@ test('layers', t => { // TODO this tests fake functionality. Move layering tests t.end(); }); +test('getLayerOrder returns result of renderer getDrawableOrder or null if renderer is not attached', t => { + const s = new Sprite(); + const r = new Runtime(); + const a = new RenderedTarget(s, r); + + // getLayerOrder should return null if there is no renderer attached to the runtime + t.equal(a.getLayerOrder(), null); + + const renderer = new FakeRenderer(); + r.attachRenderer(renderer); + const b = new RenderedTarget(s, r); + + t.equal(b.getLayerOrder(), 'stub'); + + t.end(); +}); + test('keepInFence', t => { const s = new Sprite(); const r = new Runtime(); diff --git a/test/unit/util_math.js b/test/unit/util_math.js index d5a1c32fc..a57f0a015 100644 --- a/test/unit/util_math.js +++ b/test/unit/util_math.js @@ -42,3 +42,9 @@ test('tan', t => { t.strictEqual(math.tan(33), 0.6494075932); t.end(); }); + +test('reducedSortOrdering', t => { + t.deepEqual(math.reducedSortOrdering([5, 18, 6, 3]), [1, 3, 2, 0]); + t.deepEqual(math.reducedSortOrdering([5, 1, 56, 19]), [1, 0, 3, 2]); + t.end(); +});