846 lines
33 KiB
JavaScript
846 lines
33 KiB
JavaScript
const log = require('../util/log');
|
|
const Cast = require('../util/cast');
|
|
const VariablePool = require('./variable-pool');
|
|
const execute = require('./execute');
|
|
const {disableToString} = require('./util');
|
|
|
|
/* eslint-disable max-len */
|
|
|
|
const sanitize = string => string
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/'/g, '\\\'')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r');
|
|
|
|
const TYPE_NUMBER = 1;
|
|
const TYPE_STRING = 2;
|
|
const TYPE_BOOLEAN = 3;
|
|
const TYPE_UNKNOWN = 4;
|
|
const TYPE_NUMBER_NAN = 5;
|
|
|
|
// Pen-related constants
|
|
const pen = 'runtime.ext_pen';
|
|
const penState = `${pen}._getPenState(target)`;
|
|
|
|
/**
|
|
* Variable pool used for factory function names.
|
|
*/
|
|
const factoryNameVariablePool = new VariablePool('factory');
|
|
|
|
/**
|
|
* Variable pool used for generated functions (non-generator)
|
|
*/
|
|
const functionNameVariablePool = new VariablePool('fun');
|
|
|
|
/**
|
|
* Variable pool used for generated generator functions.
|
|
*/
|
|
const generatorNameVariablePool = new VariablePool('gen');
|
|
|
|
/**
|
|
* @typedef Input
|
|
* @property {() => string} asNumber
|
|
* @property {() => string} asString
|
|
* @property {() => string} asBoolean
|
|
* @property {() => string} asUnknown
|
|
* @property {() => boolean} isAlwaysNumber
|
|
* @property {() => boolean} isAlwaysNumberOrNaN
|
|
* @property {() => boolean} isNeverNumber
|
|
*/
|
|
|
|
/**
|
|
* @implements {Input}
|
|
*/
|
|
class TypedInput {
|
|
constructor (source, type) {
|
|
// for debugging
|
|
if (typeof type !== 'number') throw new Error('type is invalid');
|
|
/** @private */
|
|
this.source = source;
|
|
/** @private */
|
|
this.type = type;
|
|
}
|
|
|
|
asNumber () {
|
|
if (this.type === TYPE_NUMBER) return this.source;
|
|
if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`;
|
|
return `(+${this.source} || 0)`;
|
|
}
|
|
|
|
asString () {
|
|
if (this.type === TYPE_STRING) return this.source;
|
|
return `("" + ${this.source})`;
|
|
}
|
|
|
|
asBoolean () {
|
|
if (this.type === TYPE_BOOLEAN) return this.source;
|
|
return `toBoolean(${this.source})`;
|
|
}
|
|
|
|
asUnknown () {
|
|
return this.source;
|
|
}
|
|
|
|
isAlwaysNumber () {
|
|
return this.type === TYPE_NUMBER;
|
|
}
|
|
|
|
isAlwaysNumberOrNaN () {
|
|
return this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN;
|
|
}
|
|
|
|
isNeverNumber () {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @implements {Input}
|
|
*/
|
|
class ConstantInput {
|
|
constructor (constantValue) {
|
|
this.constantValue = constantValue;
|
|
}
|
|
|
|
asNumber () {
|
|
// Compute at compilation time
|
|
const numberValue = +this.constantValue;
|
|
if (numberValue) {
|
|
return this.constantValue;
|
|
}
|
|
return '0';
|
|
}
|
|
|
|
asString () {
|
|
return `"${sanitize('' + this.constantValue)}"`;
|
|
}
|
|
|
|
asBoolean () {
|
|
// Compute at compilation time
|
|
return Cast.toBoolean(this.constantValue).toString();
|
|
}
|
|
|
|
asUnknown () {
|
|
// Attempt to convert strings to numbers, if it is unlikely to break things
|
|
if (typeof this.constantValue === 'number') {
|
|
// todo: handle NaN?
|
|
return this.constantValue;
|
|
}
|
|
const numberValue = +this.constantValue;
|
|
if (numberValue.toString() === this.constantValue) {
|
|
return this.constantValue;
|
|
}
|
|
return this.asString();
|
|
}
|
|
|
|
isAlwaysNumber () {
|
|
return !Number.isNaN(+this.constantValue);
|
|
}
|
|
|
|
isAlwaysNumberOrNaN () {
|
|
return this.isAlwaysNumber();
|
|
}
|
|
|
|
isNeverNumber () {
|
|
return Number.isNaN(+this.constantValue);
|
|
}
|
|
}
|
|
|
|
// Running toString() on any of these methods is a mistake.
|
|
disableToString(ConstantInput.prototype);
|
|
disableToString(ConstantInput.prototype.asNumber);
|
|
disableToString(ConstantInput.prototype.asString);
|
|
disableToString(ConstantInput.prototype.asBoolean);
|
|
disableToString(ConstantInput.prototype.asUnknown);
|
|
disableToString(TypedInput.prototype);
|
|
disableToString(TypedInput.prototype.asNumber);
|
|
disableToString(TypedInput.prototype.asString);
|
|
disableToString(TypedInput.prototype.asBoolean);
|
|
disableToString(TypedInput.prototype.asUnknown);
|
|
|
|
class ScriptCompiler {
|
|
constructor (script, ast, target) {
|
|
this.script = script;
|
|
this.ast = ast;
|
|
this.target = target;
|
|
this.source = '';
|
|
|
|
this.isWarp = script.isWarp;
|
|
this.isProcedure = script.isProcedure;
|
|
this.loopStuckChecking = script.loopStuckChecking;
|
|
|
|
this.localVariables = new VariablePool('a');
|
|
this._setupVariablesPool = new VariablePool('b');
|
|
this._setupVariables = {};
|
|
}
|
|
|
|
/**
|
|
* @param {object} node Input node to compile.
|
|
* @returns {Input} Compiled input.
|
|
*/
|
|
descendInput (node) {
|
|
switch (node.kind) {
|
|
case 'args.boolean':
|
|
return new TypedInput(`toBoolean(p${node.index})`, TYPE_BOOLEAN);
|
|
case 'args.stringNumber':
|
|
return new TypedInput(`p${node.index}`, TYPE_BOOLEAN);
|
|
|
|
case 'compat':
|
|
return new TypedInput(`(${this.generateCompatibilityLayerCall(node)})`, TYPE_UNKNOWN);
|
|
|
|
case 'constant':
|
|
return this.safeConstantInput(node.value);
|
|
|
|
case 'keyboard.pressed':
|
|
return new TypedInput(`ioQuery("keyboard", "getKeyIsDown", [${this.descendInput(node.key).asUnknown()}])`, TYPE_BOOLEAN);
|
|
|
|
case 'list.contains':
|
|
return new TypedInput(`listContains(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_BOOLEAN);
|
|
case 'list.contents':
|
|
return new TypedInput(`listContents(${this.referenceVariable(node.list)})`, TYPE_STRING);
|
|
case 'list.get': {
|
|
const index = this.descendInput(node.index);
|
|
if (index.isAlwaysNumber()) {
|
|
return new TypedInput(`listGetFast(${this.referenceVariable(node.list)}.value, ${index.asNumber()})`, TYPE_UNKNOWN);
|
|
}
|
|
return new TypedInput(`listGet(${this.referenceVariable(node.list)}.value, ${index.asUnknown()})`, TYPE_UNKNOWN);
|
|
}
|
|
case 'list.indexOf':
|
|
return new TypedInput(`listIndexOf(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_NUMBER);
|
|
case 'list.length':
|
|
return new TypedInput(`${this.referenceVariable(node.list)}.value.length`, TYPE_NUMBER);
|
|
|
|
case 'looks.size':
|
|
return new TypedInput('target.size', TYPE_NUMBER);
|
|
case 'looks.backdropName':
|
|
return new TypedInput('stage.getCostumes()[stage.currentCostume].name', TYPE_STRING);
|
|
case 'looks.backdropNumber':
|
|
return new TypedInput('(stage.currentCostume + 1)', TYPE_NUMBER);
|
|
case 'looks.costumeName':
|
|
return new TypedInput('target.getCostumes()[target.currentCostume].name', TYPE_STRING);
|
|
case 'looks.costumeNumber':
|
|
return new TypedInput('(target.currentCostume + 1)', TYPE_NUMBER);
|
|
|
|
case 'motion.direction':
|
|
return new TypedInput('target.direction', TYPE_NUMBER);
|
|
case 'motion.x':
|
|
return new TypedInput('target.x', TYPE_NUMBER);
|
|
case 'motion.y':
|
|
return new TypedInput('target.y', TYPE_NUMBER);
|
|
|
|
case 'mouse.down':
|
|
return new TypedInput('ioQuery("mouse", "getIsDown")', TYPE_BOOLEAN);
|
|
case 'mouse.x':
|
|
return new TypedInput('ioQuery("mouse", "getScratchX")', TYPE_NUMBER);
|
|
case 'mouse.y':
|
|
return new TypedInput('ioQuery("mouse", "getScratchY")', TYPE_NUMBER);
|
|
|
|
case 'op.abs':
|
|
return new TypedInput(`Math.abs(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.acos':
|
|
return new TypedInput(`((Math.acos(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER);
|
|
case 'op.add':
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} + ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.and':
|
|
return new TypedInput(`(${this.descendInput(node.left).asBoolean()} && ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN);
|
|
case 'op.asin':
|
|
return new TypedInput(`((Math.asin(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER);
|
|
case 'op.atan':
|
|
return new TypedInput(`((Math.atan(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER);
|
|
case 'op.ceiling':
|
|
return new TypedInput(`Math.ceil(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.contains':
|
|
return new TypedInput(`(${this.descendInput(node.string).asString()}.toLowerCase().indexOf(${this.descendInput(node.contains).asString()}.toLowerCase()) !== -1)`, TYPE_BOOLEAN);
|
|
case 'op.cos':
|
|
return new TypedInput(`(Math.round(Math.cos((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER);
|
|
case 'op.divide':
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} / ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.equals': {
|
|
const left = this.descendInput(node.left);
|
|
const right = this.descendInput(node.right);
|
|
// If both arguments are known to be numbers, we will use the faster === instead.
|
|
if (left.isAlwaysNumber() && right.isAlwaysNumber()) {
|
|
return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
// If either argument is known to never be a valid number, only use string comparison.
|
|
if (left.isNeverNumber() || right.isNeverNumber()) {
|
|
return new TypedInput(`(${left.asString()}.toLowerCase() === ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN);
|
|
}
|
|
return new TypedInput(`compareEqual(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN);
|
|
}
|
|
case 'op.e^':
|
|
return new TypedInput(`Math.exp(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.floor':
|
|
return new TypedInput(`Math.floor(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.greater': {
|
|
const left = this.descendInput(node.left);
|
|
const right = this.descendInput(node.right);
|
|
// If both arguments are known to be numbers, we will use the faster > instead.
|
|
if (left.isAlwaysNumber() && right.isAlwaysNumber()) {
|
|
return new TypedInput(`(${left.asNumber()} > ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
return new TypedInput(`compareGreaterThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN);
|
|
}
|
|
case 'op.join':
|
|
return new TypedInput(`(${this.descendInput(node.left).asString()} + ${this.descendInput(node.right).asString()})`, TYPE_STRING);
|
|
case 'op.length':
|
|
return new TypedInput(`${this.descendInput(node.string).asString()}.length`, TYPE_NUMBER);
|
|
case 'op.less': {
|
|
const left = this.descendInput(node.left);
|
|
const right = this.descendInput(node.right);
|
|
// If both arguments are known to be numbers, we will use the faster < instead.
|
|
if (left.isAlwaysNumber() && right.isAlwaysNumber()) {
|
|
return new TypedInput(`(${left.asNumber()} < ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
return new TypedInput(`compareLessThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN);
|
|
}
|
|
case 'op.letterOf':
|
|
return new TypedInput(`((${this.descendInput(node.string).asString()})[(${this.descendInput(node.letter).asNumber()} | 0) - 1] || "")`, TYPE_STRING);
|
|
case 'op.ln':
|
|
return new TypedInput(`Math.log(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.log':
|
|
return new TypedInput(`(Math.log(${this.descendInput(node.value).asNumber()}) / Math.LN10)`, TYPE_NUMBER);
|
|
case 'op.mod':
|
|
return new TypedInput(`mod(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.multiply':
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} * ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.not':
|
|
return new TypedInput(`!${this.descendInput(node.operand).asBoolean()}`, TYPE_BOOLEAN);
|
|
case 'op.or':
|
|
return new TypedInput(`(${this.descendInput(node.left).asBoolean()} || ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN);
|
|
case 'op.random':
|
|
if (node.useInts) {
|
|
return new TypedInput(`randomInt(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER);
|
|
}
|
|
if (node.useFloats) {
|
|
return new TypedInput(`randomFloat(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER);
|
|
}
|
|
return new TypedInput(`runtime.ext_scratch3_operators._random(${this.descendInput(node.low).asUnknown()}, ${this.descendInput(node.high).asUnknown()})`, TYPE_NUMBER);
|
|
case 'op.round':
|
|
return new TypedInput(`Math.round(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.sin':
|
|
return new TypedInput(`(Math.round(Math.sin((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER);
|
|
case 'op.sqrt':
|
|
return new TypedInput(`Math.sqrt(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.subtract':
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} - ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.tan':
|
|
return new TypedInput(`Math.tan(${this.descendInput(node.value).asNumber()} * Math.PI / 180)`, TYPE_NUMBER);
|
|
case 'op.10^':
|
|
return new TypedInput(`Math.pow(10, ${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
|
|
case 'sensing.colorTouchingColor':
|
|
return new TypedInput(`target.colorIsTouchingColor(colorToList(${this.descendInput(node.target).asUnknown()}), colorToList(${this.descendInput(node.mask).asUnknown()}))`, TYPE_BOOLEAN);
|
|
case 'sensing.touching':
|
|
return new TypedInput(`target.isTouchingObject(${this.descendInput(node.object).asUnknown()})`, TYPE_BOOLEAN);
|
|
case 'sensing.touchingColor':
|
|
return new TypedInput(`target.isTouchingColor(colorToList(${this.descendInput(node.color).asUnknown()}))`, TYPE_BOOLEAN);
|
|
case 'sensing.username':
|
|
return new TypedInput('ioQuery("userData", "getUsername")', TYPE_STRING);
|
|
|
|
case 'timer.get':
|
|
return new TypedInput('ioQuery("clock", "projectTimer")', TYPE_NUMBER);
|
|
|
|
case 'tw.lastKeyPressed':
|
|
// Not final.
|
|
return new TypedInput('ioQuery("keyboard", "getLastKeyPressed")', TYPE_STRING);
|
|
|
|
case 'var.get':
|
|
return new TypedInput(`${this.referenceVariable(node.variable)}.value`, TYPE_UNKNOWN);
|
|
|
|
default:
|
|
log.warn(`JS: Unknown input: ${node.kind}`, node);
|
|
throw new Error(`JS: Unknown input: ${node.kind}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {*} node Stacked node to compile.
|
|
*/
|
|
descendStackedBlock (node) {
|
|
switch (node.kind) {
|
|
case 'compat':
|
|
this.source += this.generateCompatibilityLayerCall(node);
|
|
this.source += ';\n';
|
|
break;
|
|
|
|
case 'control.createClone':
|
|
this.source += `runtime.ext_scratch3_control._createClone(${this.descendInput(node.target).asString()}, target);\n`;
|
|
break;
|
|
case 'control.deleteClone':
|
|
this.source += 'if (!target.isOriginal) {\n';
|
|
this.source += ' runtime.disposeTarget(target);\n';
|
|
this.source += ' runtime.stopForTarget(target);\n';
|
|
this.retire();
|
|
this.source += '}\n';
|
|
break;
|
|
case 'control.for': {
|
|
const index = this.localVariables.next();
|
|
this.source += `var ${index} = 0; `;
|
|
this.source += `while (${index} < ${this.descendInput(node.count).asNumber()}) { `;
|
|
this.source += `${index}++; `;
|
|
this.source += `${this.referenceVariable(node.variable)}.value = ${index};\n`;
|
|
this.descendStack(node.do);
|
|
this.source += '}\n';
|
|
break;
|
|
}
|
|
case 'control.if':
|
|
this.source += `if (${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.descendStack(node.whenTrue);
|
|
// only add the else branch if it won't be empty
|
|
// this makes scripts have a bit less useless noise in them
|
|
if (node.whenFalse.length) {
|
|
this.source += `} else {\n`;
|
|
this.descendStack(node.whenFalse);
|
|
}
|
|
this.source += `}\n`;
|
|
break;
|
|
case 'control.repeat': {
|
|
const i = this.localVariables.next();
|
|
this.source += `for (var ${i} = ${this.descendInput(node.times).asNumber()}; ${i} >= 0.5; ${i}--) {\n`;
|
|
this.descendStack(node.do);
|
|
this.yieldLoop();
|
|
this.source += `}\n`;
|
|
break;
|
|
}
|
|
case 'control.stopAll':
|
|
this.source += 'runtime.stopAll();\n';
|
|
this.retire();
|
|
break;
|
|
case 'control.stopOthers':
|
|
this.source += 'runtime.stopForTarget(target, thread);\n';
|
|
break;
|
|
case 'control.stopScript':
|
|
if (this.isProcedure) {
|
|
this.source += 'return;\n';
|
|
} else {
|
|
this.retire();
|
|
}
|
|
break;
|
|
case 'control.wait': {
|
|
const timer = this.localVariables.next();
|
|
const duration = this.localVariables.next();
|
|
this.source += `var ${timer} = timer();\n`;
|
|
this.source += `var ${duration} = Math.max(0, 1000 * ${this.descendInput(node.seconds).asNumber()});\n`;
|
|
this.requestRedraw();
|
|
// always yield at least once, even on 0 second durations
|
|
this.yieldNotWarp();
|
|
this.source += `while (${timer}.timeElapsed() < ${duration}) {\n`;
|
|
this.yieldNotWarpOrStuck();
|
|
this.source += '}\n';
|
|
break;
|
|
}
|
|
case 'control.waitUntil': {
|
|
this.source += `while (!${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.yieldNotWarpOrStuck();
|
|
this.source += `}\n`;
|
|
break;
|
|
}
|
|
case 'control.while':
|
|
this.source += `while (${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.descendStack(node.do);
|
|
this.yieldLoop();
|
|
this.source += `}\n`;
|
|
break;
|
|
|
|
case 'event.broadcast':
|
|
this.source += `startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });\n`;
|
|
break;
|
|
case 'event.broadcastAndWait':
|
|
this.source += `yield* waitThreads(startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} }));\n`;
|
|
break;
|
|
|
|
case 'list.add': {
|
|
const list = this.referenceVariable(node.list);
|
|
this.source += `${list}.value.push(${this.descendInput(node.item).asUnknown()});\n`;
|
|
this.source += `${list}._monitorUpToDate = false;\n`;
|
|
break;
|
|
}
|
|
case 'list.delete':
|
|
this.source += `listDelete(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()});\n`;
|
|
break;
|
|
case 'list.deleteAll':
|
|
this.source += `${this.referenceVariable(node.list)}.value = [];\n`;
|
|
break;
|
|
case 'list.hide':
|
|
this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: false }, runtime);\n`;
|
|
break;
|
|
case 'list.insert':
|
|
this.source += `listInsert(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asUnknown()});\n`;
|
|
break;
|
|
case 'list.replace':
|
|
this.source += `listReplace(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asUnknown()});\n`;
|
|
break;
|
|
case 'list.show':
|
|
this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: true }, runtime);\n`;
|
|
break;
|
|
|
|
case 'looks.backwardLayers':
|
|
this.source += `target.goBackwardLayers(${this.descendInput(node.layers).asNumber()});\n`;
|
|
break;
|
|
case 'looks.clearEffects':
|
|
this.source += 'target.clearEffects();\n';
|
|
break;
|
|
case 'looks.forwardLayers':
|
|
this.source += `target.goForwardLayers(${this.descendInput(node.layers).asNumber()});\n`;
|
|
break;
|
|
case 'looks.goToBack':
|
|
this.source += 'target.goToBack();\n';
|
|
break;
|
|
case 'looks.goToFront':
|
|
this.source += 'target.goToFront();\n';
|
|
break;
|
|
case 'looks.hide':
|
|
this.source += 'target.setVisible(false);\n';
|
|
this.source += 'runtime.ext_scratch3_looks._renderBubble(target);\n';
|
|
break;
|
|
case 'looks.setSize':
|
|
this.source += `target.setSize(${this.descendInput(node.size).asNumber()});\n`;
|
|
break;
|
|
case 'looks.show':
|
|
this.source += 'target.setVisible(true);\n';
|
|
this.source += 'runtime.ext_scratch3_looks._renderBubble(target);\n';
|
|
break;
|
|
case 'looks.switchBackdrop':
|
|
this.source += `runtime.ext_scratch3_looks._setBackdrop(stage, ${this.descendInput(node.backdrop).asUnknown()});\n`;
|
|
break;
|
|
case 'looks.switchCostume':
|
|
this.source += `runtime.ext_scratch3_looks._setCostume(target, ${this.descendInput(node.costume).asUnknown()});\n`;
|
|
break;
|
|
|
|
case 'motion.ifOnEdgeBounce':
|
|
this.source += `runtime.ext_scratch3_motion._ifOnEdgeBounce(target);\n`;
|
|
break;
|
|
case 'motion.setDirection':
|
|
this.source += `target.setDirection(${this.descendInput(node.direction).asNumber()});\n`;
|
|
break;
|
|
case 'motion.setRotationStyle':
|
|
this.source += `target.setRotationStyle("${sanitize(node.style)}");\n`;
|
|
break;
|
|
case 'motion.setXY':
|
|
this.source += `target.setXY(${this.descendInput(node.x).asNumber()}, ${this.descendInput(node.y).asNumber()});\n`;
|
|
break;
|
|
case 'motion.step':
|
|
this.source += `runtime.ext_scratch3_motion._moveSteps(${this.descendInput(node.steps).asNumber()}, target);\n`;
|
|
break;
|
|
|
|
case 'noop':
|
|
// todo: remove noop entirely
|
|
break;
|
|
|
|
case 'pen.clear':
|
|
this.source += `${pen}.clear();\n`;
|
|
break;
|
|
case 'pen.down':
|
|
this.source += `${pen}._penDown(target);\n`;
|
|
break;
|
|
case 'pen.changeParam':
|
|
this.source += `${pen}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${penState}, true);\n`;
|
|
break;
|
|
case 'pen.changeSize':
|
|
this.source += `${pen}._changePenSizeBy(${this.descendInput(node.size).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacyChangeHue':
|
|
this.source += `${pen}._changePenHueBy(${this.descendInput(node.hue).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacyChangeShade':
|
|
this.source += `${pen}._changePenShadeBy(${this.descendInput(node.shade).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacySetHue':
|
|
this.source += `${pen}._setPenHueToNumber(${this.descendInput(node.hue).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacySetShade':
|
|
this.source += `${pen}._setPenShadeToNumber(${this.descendInput(node.shade).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.setColor':
|
|
this.source += `${pen}._setPenColorToColor(${this.descendInput(node.color).asUnknown()}, target);\n`;
|
|
break;
|
|
case 'pen.setParam':
|
|
this.source += `${pen}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${penState}, false);\n`;
|
|
break;
|
|
case 'pen.setSize':
|
|
this.source += `${pen}._setPenSizeTo(${this.descendInput(node.size).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.stamp':
|
|
this.source += `${pen}._stamp(target);\n`;
|
|
break;
|
|
case 'pen.up':
|
|
this.source += `${pen}._penUp(target);\n`;
|
|
break;
|
|
|
|
case 'procedures.call': {
|
|
const procedureCode = node.code;
|
|
// Do not generate any code for empty procedures.
|
|
const procedureData = this.ast.procedures[procedureCode];
|
|
if (procedureData.stack === null) {
|
|
break;
|
|
}
|
|
const callingFromNonWarpToWarp = !this.isWarp && procedureData.isWarp;
|
|
if (callingFromNonWarpToWarp) {
|
|
this.source += 'thread.warp++;\n';
|
|
}
|
|
if (procedureData.yields) {
|
|
this.source += 'yield* ';
|
|
}
|
|
this.source += `thread.procedures["${sanitize(procedureCode)}"](`;
|
|
// Only include arguments if the procedure accepts any.
|
|
if (procedureData.arguments.length) {
|
|
const args = [];
|
|
for (const input of node.arguments) {
|
|
args.push(this.descendInput(input).asUnknown());
|
|
}
|
|
this.source += args.join(',');
|
|
}
|
|
this.source += `);\n`;
|
|
if (callingFromNonWarpToWarp) {
|
|
this.source += 'thread.warp--;\n';
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'timer.reset':
|
|
this.source += 'ioQuery("clock", "resetProjectTimer");\n';
|
|
break;
|
|
|
|
case 'var.change': {
|
|
const variable = this.referenceVariable(node.variable);
|
|
this.source += `${variable}.value = (+${variable}.value || 0) + ${this.descendInput(node.value).asNumber()};\n`;
|
|
if (node.variable.isCloud) {
|
|
this.source += `ioQuery("cloud", "requestUpdateVariable", ["${sanitize(node.variable.name)}", ${variable}.value]);\n`;
|
|
}
|
|
break;
|
|
}
|
|
case 'var.hide':
|
|
this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: false }, runtime);\n`;
|
|
break;
|
|
case 'var.set': {
|
|
const variable = this.referenceVariable(node.variable);
|
|
this.source += `${variable}.value = ${this.descendInput(node.value).asUnknown()};\n`;
|
|
if (node.variable.isCloud) {
|
|
this.source += `ioQuery("cloud", "requestUpdateVariable", ["${sanitize(node.variable.name)}", ${variable}.value]);\n`;
|
|
}
|
|
break;
|
|
}
|
|
case 'var.show':
|
|
this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: true }, runtime);\n`;
|
|
break;
|
|
|
|
default:
|
|
log.warn(`JS: Unknown stacked block: ${node.kind}`, node);
|
|
throw new Error(`JS: Unknown stacked block: ${node.kind}`);
|
|
}
|
|
}
|
|
|
|
descendStack (nodes) {
|
|
for (const node of nodes) {
|
|
this.descendStackedBlock(node);
|
|
}
|
|
}
|
|
|
|
referenceVariable (variable) {
|
|
if (variable.scope === 'target') {
|
|
return this.evaluateOnce(`target.variables["${sanitize(variable.id)}"]`);
|
|
}
|
|
return this.evaluateOnce(`stage.variables["${sanitize(variable.id)}"]`);
|
|
}
|
|
|
|
evaluateOnce (source) {
|
|
if (this._setupVariables.hasOwnProperty(source)) {
|
|
return this._setupVariables[source];
|
|
}
|
|
const variable = this._setupVariablesPool.next();
|
|
this._setupVariables[source] = variable;
|
|
return variable;
|
|
}
|
|
|
|
retire () {
|
|
// After running retire(), we need to return to the event loop.
|
|
// When in a procedure, return will only send us back to the previous procedure.
|
|
// So instead, we yield back to the event loop.
|
|
// Outside of a procedure, return will work correctly.
|
|
if (this.isProcedure) {
|
|
this.source += 'retire(); yield;\n';
|
|
} else {
|
|
this.source += 'retire(); return;\n';
|
|
}
|
|
}
|
|
|
|
yieldLoop () {
|
|
if (this.loopStuckChecking) {
|
|
this.yieldNotWarpOrStuck();
|
|
} else {
|
|
this.yieldNotWarp();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write JS to yield the current thread if warp mode is disabled.
|
|
*/
|
|
yieldNotWarp () {
|
|
if (!this.isWarp) {
|
|
this.source += 'if (thread.warp === 0) yield;\n';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write JS to yield the current thread if warp mode is disabled or if the script seems to be stuck.
|
|
*/
|
|
yieldNotWarpOrStuck () {
|
|
if (this.isWarp) {
|
|
this.source += 'if (isStuck()) yield;\n';
|
|
} else {
|
|
this.source += 'if (thread.warp === 0 || isStuck()) yield;\n';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write JS to request a redraw.
|
|
*/
|
|
requestRedraw () {
|
|
this.source += 'runtime.requestRedraw();\n';
|
|
}
|
|
|
|
safeConstantInput (value) {
|
|
if (typeof value === 'string') {
|
|
if (this.isNameOfCostume(value)) {
|
|
return new TypedInput(`"${sanitize(value)}"`, TYPE_STRING);
|
|
}
|
|
}
|
|
return new ConstantInput(value);
|
|
}
|
|
|
|
isNameOfCostume (stringValue) {
|
|
for (const target of this.target.runtime.targets) {
|
|
if (target.isOriginal) {
|
|
if (target.getCostumeIndexByName(stringValue) !== -1) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate a call into the compatibility layer.
|
|
* @param {*} node The "compat" kind node to generate from.
|
|
* @returns {string} The JS of the call.
|
|
*/
|
|
generateCompatibilityLayerCall (node) {
|
|
const opcode = node.opcode;
|
|
|
|
let result = 'yield* executeInCompatibilityLayer({';
|
|
|
|
for (const inputName of Object.keys(node.inputs)) {
|
|
const input = node.inputs[inputName];
|
|
const compiledInput = this.descendInput(input).asUnknown();
|
|
result += `"${sanitize(inputName)}":${compiledInput},`;
|
|
}
|
|
for (const fieldName of Object.keys(node.fields)) {
|
|
const field = node.fields[fieldName];
|
|
result += `"${sanitize(fieldName)}":"${sanitize(field)}",`;
|
|
}
|
|
const opcodeFunction = this.evaluateOnce(`runtime.getOpcodeFunction("${sanitize(opcode)}")`);
|
|
result += `}, ${opcodeFunction})`;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generate the JS to pass into eval() based on the current state of the compiler.
|
|
* @returns {string} JS to pass into eval()
|
|
*/
|
|
createScriptFactory () {
|
|
let script = '';
|
|
|
|
// Setup the factory
|
|
script += `(function ${factoryNameVariablePool.next()}(target) { `;
|
|
script += 'const runtime = target.runtime; ';
|
|
script += 'const stage = runtime.getTargetForStage();\n';
|
|
for (const varValue of Object.keys(this._setupVariables)) {
|
|
const varName = this._setupVariables[varValue];
|
|
script += `const ${varName} = ${varValue};\n`;
|
|
}
|
|
|
|
// Generated script
|
|
script += 'return ';
|
|
if (this.script.yields) {
|
|
script += `function* ${generatorNameVariablePool.next()} `;
|
|
} else {
|
|
script += `function ${functionNameVariablePool.next()} `;
|
|
}
|
|
script += '(';
|
|
if (this.script.arguments.length) {
|
|
const args = [];
|
|
for (let i = 0; i < this.script.arguments.length; i++) {
|
|
args.push(`p${i}`);
|
|
}
|
|
script += args.join(',');
|
|
}
|
|
script += ') {\n';
|
|
|
|
script += this.source;
|
|
|
|
if (!this.isProcedure) {
|
|
script += 'retire();\n';
|
|
}
|
|
|
|
script += '}; })';
|
|
|
|
return script;
|
|
}
|
|
|
|
/**
|
|
* Compile this script.
|
|
* @returns {Function} The factory function for the tree.
|
|
*/
|
|
compile () {
|
|
if (this.script.stack) {
|
|
this.descendStack(this.script.stack);
|
|
}
|
|
|
|
const factory = this.createScriptFactory();
|
|
const fn = execute.scopedEval(factory);
|
|
|
|
log.info(`JS: ${this.target.sprite.name}: compiled ${this.script.procedureCode || 'script'}`, factory);
|
|
|
|
return fn;
|
|
}
|
|
}
|
|
|
|
class JSCompiler {
|
|
constructor (ast, target) {
|
|
this.ast = ast;
|
|
this.target = target;
|
|
this.procedures = {};
|
|
}
|
|
|
|
compileTree (script) {
|
|
if (script.cachedCompileResult) {
|
|
return script.cachedCompileResult;
|
|
}
|
|
|
|
const compiler = new ScriptCompiler(script, this.ast, this.target);
|
|
const result = compiler.compile();
|
|
script.cachedCompileResult = result;
|
|
return result;
|
|
}
|
|
|
|
compile () {
|
|
const entry = this.compileTree(this.ast.entry);
|
|
|
|
for (const procedureCode of Object.keys(this.ast.procedures)) {
|
|
const procedureData = this.ast.procedures[procedureCode];
|
|
const procedureTree = this.compileTree(procedureData);
|
|
this.procedures[procedureCode] = procedureTree;
|
|
}
|
|
|
|
return {
|
|
startingFunction: entry,
|
|
procedures: this.procedures
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = JSCompiler;
|