Skip to content

Commit 8a93e20

Browse files
committed
vanilla compat so yum
1 parent 3825127 commit 8a93e20

2 files changed

Lines changed: 123 additions & 5 deletions

File tree

src/serialization/sb3.js

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,33 @@ const EXTENDABLE_OPERATORS = {
112112
operator_join: 'STRING'
113113
};
114114

115+
// Blocks that vanilla Scratch has no opcode for, but which are constant enough to be saved as a
116+
// stage variable holding the value. Vanilla runs them as an ordinary variable reporter; MistWarp
117+
// turns them back into blocks on load. The id is the name so the same variable is reused forever.
118+
const VANILLA_CONSTANTS = {
119+
operator_pi: {name: 'mistwarp.pi', value: Math.PI},
120+
operator_newline: {name: 'mistwarp.newline', value: '\n'}
121+
};
122+
123+
const CONSTANT_OPCODE_BY_NAME = {};
124+
for (const opcode in VANILLA_CONSTANTS) {
125+
CONSTANT_OPCODE_BY_NAME[VANILLA_CONSTANTS[opcode].name] = opcode;
126+
}
127+
128+
const collapseConstants = function (blocks) {
129+
for (const id in blocks) {
130+
if (!hasOwnProperty.call(blocks, id)) continue;
131+
const block = blocks[id];
132+
if (!block || Array.isArray(block) || block.opcode !== 'data_variable') continue;
133+
const variable = block.fields && block.fields.VARIABLE;
134+
const opcode = variable && CONSTANT_OPCODE_BY_NAME[variable.value];
135+
if (!opcode) continue;
136+
block.opcode = opcode;
137+
block.fields = {};
138+
}
139+
return blocks;
140+
};
141+
115142
const getOperatorItemCount = block => {
116143
const mutation = block.mutation;
117144
if (mutation && mutation.itemcount) {
@@ -199,7 +226,7 @@ const collapseOperators = function (blocks) {
199226
return blocks;
200227
};
201228

202-
const expandOperators = function (blocks) {
229+
const expandOperators = function (blocks, usedConstants) {
203230
const result = {};
204231
for (const id in blocks) {
205232
if (hasOwnProperty.call(blocks, id)) result[id] = blocks[id];
@@ -225,7 +252,23 @@ const expandOperators = function (blocks) {
225252
const originalIds = Object.keys(result);
226253
for (const id of originalIds) {
227254
const orig = result[id];
228-
if (!orig || Array.isArray(orig) || !orig.inputs) continue;
255+
if (!orig || Array.isArray(orig)) continue;
256+
const constant = VANILLA_CONSTANTS[orig.opcode];
257+
if (constant) {
258+
const block = cloneBlock(id);
259+
block.opcode = 'data_variable';
260+
block.fields = {
261+
VARIABLE: {
262+
name: 'VARIABLE',
263+
value: constant.name,
264+
id: constant.name,
265+
variableType: Variable.SCALAR_TYPE
266+
}
267+
};
268+
if (usedConstants) usedConstants.add(constant);
269+
continue;
270+
}
271+
if (!orig.inputs) continue;
229272
const prefix = EXTENDABLE_OPERATORS[orig.opcode];
230273
if (!prefix) continue;
231274
const count = getOperatorItemCount(orig);
@@ -795,7 +838,7 @@ const serializeFrames = function (frames) {
795838
* @param {Set} extensions A set of extensions to add extension IDs to
796839
* @return {object} A serialized representation of the given target.
797840
*/
798-
const serializeTarget = function (target, extensions) {
841+
const serializeTarget = function (target, extensions, usedConstants) {
799842
const obj = Object.create(null);
800843
let targetExtensions = [];
801844
obj.isStage = target.isStage;
@@ -804,7 +847,7 @@ const serializeTarget = function (target, extensions) {
804847
obj.variables = vars.variables;
805848
obj.lists = vars.lists;
806849
obj.broadcasts = vars.broadcasts;
807-
[obj.blocks, targetExtensions] = serializeBlocks(expandOperators(target.blocks));
850+
[obj.blocks, targetExtensions] = serializeBlocks(expandOperators(target.blocks, usedConstants));
808851
obj.comments = serializeComments(target.comments);
809852
if (target.frames && Object.keys(target.frames).length > 0) {
810853
obj.frames = serializeFrames(target.frames);
@@ -949,7 +992,9 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {})
949992
});
950993
}
951994

952-
const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions))
995+
const usedConstants = new Set();
996+
997+
const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions, usedConstants))
953998
.map((serialized, index) => {
954999
// can't serialize extensionStorage until the list of used extensions is fully known
9551000
const target = originalTargetsToSerialize[index];
@@ -960,6 +1005,18 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {})
9601005
return serialized;
9611006
});
9621007

1008+
if (usedConstants.size) {
1009+
// Sprites are exported without a stage, so their copy of the variable has to be local.
1010+
const constantHost = targetId ?
1011+
serializedTargets[0] :
1012+
serializedTargets.find(t => t.isStage);
1013+
if (constantHost) {
1014+
for (const constant of usedConstants) {
1015+
constantHost.variables[constant.name] = [constant.name, constant.value];
1016+
}
1017+
}
1018+
}
1019+
9631020
const fonts = runtime.fontManager.serializeJSON();
9641021
const customAssets = runtime.assetManager.serializeJSON();
9651022

@@ -1398,6 +1455,7 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) {
13981455
}
13991456
if (Object.prototype.hasOwnProperty.call(object, 'blocks')) {
14001457
deserializeBlocks(object.blocks);
1458+
collapseConstants(object.blocks);
14011459
if (runtime.extendableOperators) {
14021460
collapseOperators(object.blocks);
14031461
}
@@ -1439,6 +1497,8 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) {
14391497
if (Object.prototype.hasOwnProperty.call(object, 'variables')) {
14401498
for (const varId in object.variables) {
14411499
const variable = object.variables[varId];
1500+
// Every reference to it just became a block again, so don't recreate the variable.
1501+
if (CONSTANT_OPCODE_BY_NAME[variable[0]]) continue;
14421502
// A variable is a cloud variable if:
14431503
// - the project says it's a cloud variable, and
14441504
// - it's a stage variable, and

test/unit/serialization_sb3.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,61 @@ test('extendable operators preserve obscured shadows across a load/save round tr
437437
t.end();
438438
})();
439439
});
440+
441+
// pi/newline have no vanilla opcode, so they are saved as stage variables holding their value and
442+
// turned back into blocks on load.
443+
const buildConstantsProject = () => ({
444+
targets: [
445+
{isStage: true, name: 'Stage', variables: {}, lists: {}, broadcasts: {}, blocks: {},
446+
comments: {}, currentCostume: 0, costumes: [], sounds: [], volume: 100, layerOrder: 0,
447+
tempo: 60, videoTransparency: 50, videoState: 'off', textToSpeechLanguage: null},
448+
{isStage: false, name: 'Sprite1', variables: {}, lists: {}, broadcasts: {}, blocks: {
449+
hat: {opcode: 'event_whenflagclicked', next: 'say', parent: null, inputs: {},
450+
fields: {}, shadow: false, topLevel: true, x: 0, y: 0},
451+
say: {opcode: 'looks_say', next: null, parent: 'hat',
452+
inputs: {MESSAGE: [3, 'join', [10, '']]}, fields: {}, shadow: false, topLevel: false},
453+
join: {opcode: 'operator_join', next: null, parent: 'say',
454+
inputs: {STRING1: [3, 'pi', [10, 'a']], STRING2: [3, 'newline', [10, 'b']]},
455+
fields: {}, shadow: false, topLevel: false},
456+
pi: {opcode: 'operator_pi', next: null, parent: 'join', inputs: {}, fields: {},
457+
shadow: false, topLevel: false},
458+
newline: {opcode: 'operator_newline', next: null, parent: 'join', inputs: {}, fields: {},
459+
shadow: false, topLevel: false}
460+
}, comments: {}, currentCostume: 0, costumes: [], sounds: [], volume: 100, layerOrder: 1,
461+
visible: true, x: 0, y: 0, size: 100, direction: 90, draggable: false,
462+
rotationStyle: 'all around'}
463+
], monitors: [], extensions: [], meta: {semver: '3.0.0', vm: '0.2.0', agent: ''}
464+
});
465+
466+
test('pi and newline round trip through stage variables', t => {
467+
const runtime = new Runtime();
468+
(async () => {
469+
const {targets} = await sb3.deserialize(buildConstantsProject(), runtime);
470+
for (const target of targets) runtime.addTarget(target);
471+
472+
const saved = sb3.serialize(runtime);
473+
const stage = saved.targets.find(target => target.isStage);
474+
t.same(stage.variables['mistwarp.pi'], ['mistwarp.pi', Math.PI], 'pi saved as a stage variable');
475+
t.same(stage.variables['mistwarp.newline'], ['mistwarp.newline', '\n'], 'newline saved as a stage variable');
476+
477+
const savedBlocks = saved.targets[1].blocks;
478+
const opcodes = Object.values(savedBlocks).map(block => block.opcode);
479+
t.notOk(opcodes.includes('operator_pi'), 'no operator_pi block saved');
480+
t.notOk(opcodes.includes('operator_newline'), 'no operator_newline block saved');
481+
const join = Object.values(savedBlocks).find(block => block.opcode === 'operator_join');
482+
t.same(join.inputs.STRING1[1], [12, 'mistwarp.pi', 'mistwarp.pi'], 'pi is a variable reporter');
483+
t.same(join.inputs.STRING2[1], [12, 'mistwarp.newline', 'mistwarp.newline'], 'newline is a variable reporter');
484+
485+
for (const target of targets) runtime.disposeTarget(target);
486+
487+
const reloadRuntime = new Runtime();
488+
const reloaded = await sb3.deserialize(JSON.parse(JSON.stringify(saved)), reloadRuntime);
489+
const reloadedStage = reloaded.targets.find(target => target.isStage);
490+
t.same(Object.keys(reloadedStage.variables), [], 'constant variables are not recreated on load');
491+
const reloadedOpcodes = Object.values(reloaded.targets[1].blocks._blocks).map(block => block.opcode);
492+
t.ok(reloadedOpcodes.includes('operator_pi'), 'pi block restored');
493+
t.ok(reloadedOpcodes.includes('operator_newline'), 'newline block restored');
494+
t.notOk(reloadedOpcodes.includes('data_variable'), 'no leftover variable reporters');
495+
t.end();
496+
})();
497+
});

0 commit comments

Comments
 (0)