This reverts commit b04df3eda7.
There are still subtle issues related to execution order (JS is left-to-right while
Scratch is depth-first). As there will inherently be some issues, we may as well
do what runs faster.
1368 lines
54 KiB
JavaScript
1368 lines
54 KiB
JavaScript
const log = require('../util/log');
|
|
const Cast = require('../util/cast');
|
|
const VariablePool = require('./variable-pool');
|
|
const jsexecute = require('./jsexecute');
|
|
const environment = require('./environment');
|
|
|
|
// Imported for JSDoc types, not to actually use
|
|
// eslint-disable-next-line no-unused-vars
|
|
const {IntermediateScript, IntermediateRepresentation} = require('./intermediate');
|
|
|
|
/**
|
|
* @fileoverview Convert intermediate representations to JavaScript functions.
|
|
*/
|
|
|
|
/* eslint-disable max-len */
|
|
/* eslint-disable prefer-template */
|
|
|
|
const sanitize = string => {
|
|
if (typeof string !== 'string') {
|
|
log.warn(`sanitize got unexpected type: ${typeof string}`);
|
|
string = '' + string;
|
|
}
|
|
return JSON.stringify(string).slice(1, -1);
|
|
};
|
|
|
|
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_EXT = 'runtime.ext_pen';
|
|
const PEN_STATE = `${PEN_EXT}._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} asNumberOrNaN
|
|
* @property {() => string} asString
|
|
* @property {() => string} asBoolean
|
|
* @property {() => string} asColor
|
|
* @property {() => string} asUnknown
|
|
* @property {() => string} asSafe
|
|
* @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');
|
|
this.source = source;
|
|
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)`;
|
|
}
|
|
|
|
asNumberOrNaN () {
|
|
if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source;
|
|
return `(+${this.source})`;
|
|
}
|
|
|
|
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})`;
|
|
}
|
|
|
|
asColor () {
|
|
return this.asUnknown();
|
|
}
|
|
|
|
asUnknown () {
|
|
return this.source;
|
|
}
|
|
|
|
asSafe () {
|
|
return this.asUnknown();
|
|
}
|
|
|
|
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, safe) {
|
|
this.constantValue = constantValue;
|
|
this.safe = safe;
|
|
}
|
|
|
|
asNumber () {
|
|
// Compute at compilation time
|
|
const numberValue = +this.constantValue;
|
|
if (numberValue) {
|
|
// It's important that we use the number's stringified value and not the constant value
|
|
// Using the constant value allows numbers such as "010" to be interpreted as 8 (or SyntaxError in strict mode) instead of 10.
|
|
return numberValue.toString();
|
|
}
|
|
// numberValue is one of 0, -0, or NaN
|
|
if (Object.is(numberValue, -0)) {
|
|
return '-0';
|
|
}
|
|
return '0';
|
|
}
|
|
|
|
asNumberOrNaN () {
|
|
return this.asNumber();
|
|
}
|
|
|
|
asString () {
|
|
return `"${sanitize('' + this.constantValue)}"`;
|
|
}
|
|
|
|
asBoolean () {
|
|
// Compute at compilation time
|
|
return Cast.toBoolean(this.constantValue).toString();
|
|
}
|
|
|
|
asColor () {
|
|
// Attempt to parse hex code at compilation time
|
|
if (/^#[0-9a-f]{6,8}$/i.test(this.constantValue)) {
|
|
const hex = this.constantValue.substr(1);
|
|
return Number.parseInt(hex, 16).toString();
|
|
}
|
|
return this.asUnknown();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
asSafe () {
|
|
if (this.safe) {
|
|
return this.asUnknown();
|
|
}
|
|
return this.asString();
|
|
}
|
|
|
|
isAlwaysNumber () {
|
|
const value = +this.constantValue;
|
|
if (Number.isNaN(value)) {
|
|
return false;
|
|
}
|
|
// Empty strings evaluate to 0 but should not be considered a number.
|
|
if (value === 0) {
|
|
return this.constantValue.toString().trim() !== '';
|
|
}
|
|
return true;
|
|
}
|
|
|
|
isAlwaysNumberOrNaN () {
|
|
return this.isAlwaysNumber();
|
|
}
|
|
|
|
isNeverNumber () {
|
|
return Number.isNaN(+this.constantValue);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @implements {Input}
|
|
*/
|
|
class VariableInput {
|
|
constructor (source) {
|
|
this.source = source;
|
|
this.type = TYPE_UNKNOWN;
|
|
/**
|
|
* The value this variable was most recently set to, if any.
|
|
* @type {Input}
|
|
* @private
|
|
*/
|
|
this._value = null;
|
|
}
|
|
|
|
/**
|
|
* @param {Input} input The input this variable was most recently set to.
|
|
*/
|
|
setInput (input) {
|
|
if (input instanceof VariableInput) {
|
|
// When being set to another variable, extract the value it was set to.
|
|
// Otherwise, you may end up with infinite recursion in analysis methods when a variable is set to itself.
|
|
if (input._value) {
|
|
input = input._value;
|
|
} else {
|
|
this.type = TYPE_UNKNOWN;
|
|
this._value = null;
|
|
return;
|
|
}
|
|
}
|
|
this._value = input;
|
|
if (input instanceof TypedInput) {
|
|
this.type = input.type;
|
|
} else {
|
|
this.type = TYPE_UNKNOWN;
|
|
}
|
|
}
|
|
|
|
asNumber () {
|
|
if (this.type === TYPE_NUMBER) return this.source;
|
|
if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`;
|
|
return `(+${this.source} || 0)`;
|
|
}
|
|
|
|
asNumberOrNaN () {
|
|
if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source;
|
|
return `(+${this.source})`;
|
|
}
|
|
|
|
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})`;
|
|
}
|
|
|
|
asColor () {
|
|
return this.asUnknown();
|
|
}
|
|
|
|
asUnknown () {
|
|
return this.source;
|
|
}
|
|
|
|
asSafe () {
|
|
return this.asUnknown();
|
|
}
|
|
|
|
isAlwaysNumber () {
|
|
if (this._value) {
|
|
return this._value.isAlwaysNumber();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
isAlwaysNumberOrNaN () {
|
|
if (this._value) {
|
|
return this._value.isAlwaysNumberOrNaN();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
isNeverNumber () {
|
|
if (this._value) {
|
|
return this._value.isNeverNumber();
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const getNamesOfCostumesAndSounds = runtime => {
|
|
const result = new Set();
|
|
for (const target of runtime.targets) {
|
|
if (target.isOriginal) {
|
|
const sprite = target.sprite;
|
|
for (const costume of sprite.costumes) {
|
|
result.add(costume.name);
|
|
}
|
|
for (const sound of sprite.sounds) {
|
|
result.add(sound.name);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const isSafeConstantForEqualsOptimization = input => {
|
|
const numberValue = +input.constantValue;
|
|
// Do not optimize 0
|
|
if (!numberValue) {
|
|
return false;
|
|
}
|
|
// Do not optimize numbers when the original form does not match
|
|
return numberValue.toString() === input.constantValue.toString();
|
|
};
|
|
|
|
/**
|
|
* A frame contains some information about the current substack being compiled.
|
|
*/
|
|
class Frame {
|
|
constructor (isLoop) {
|
|
/**
|
|
* Whether the current stack runs in a loop (while, for)
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
this.isLoop = isLoop;
|
|
|
|
/**
|
|
* Whether the current block is the last block in the stack.
|
|
* @type {boolean}
|
|
*/
|
|
this.isLastBlock = false;
|
|
}
|
|
}
|
|
|
|
class JSGenerator {
|
|
/**
|
|
* @param {IntermediateScript} script
|
|
* @param {IntermediateRepresentation} ir
|
|
* @param {Target} target
|
|
*/
|
|
constructor (script, ir, target) {
|
|
this.script = script;
|
|
this.ir = ir;
|
|
this.target = target;
|
|
this.source = '';
|
|
|
|
/**
|
|
* @type {Object.<string, VariableInput>}
|
|
*/
|
|
this.variableInputs = {};
|
|
|
|
this.isWarp = script.isWarp;
|
|
this.isProcedure = script.isProcedure;
|
|
this.warpTimer = script.warpTimer;
|
|
|
|
/**
|
|
* Stack of frames, most recent is last item.
|
|
* @type {Frame[]}
|
|
*/
|
|
this.frames = [];
|
|
|
|
/**
|
|
* The current Frame.
|
|
* @type {Frame}
|
|
*/
|
|
this.currentFrame = null;
|
|
|
|
this.namesOfCostumesAndSounds = getNamesOfCostumesAndSounds(target.runtime);
|
|
|
|
this.localVariables = new VariablePool('a');
|
|
this._setupVariablesPool = new VariablePool('b');
|
|
this._setupVariables = {};
|
|
|
|
this.descendedIntoModulo = false;
|
|
|
|
this.debug = this.target.runtime.debug;
|
|
}
|
|
|
|
/**
|
|
* Enter a new frame
|
|
* @param {Frame} frame New frame.
|
|
*/
|
|
pushFrame (frame) {
|
|
this.frames.push(frame);
|
|
this.currentFrame = frame;
|
|
}
|
|
|
|
/**
|
|
* Exit the current frame
|
|
*/
|
|
popFrame () {
|
|
this.frames.pop();
|
|
this.currentFrame = this.frames[this.frames.length - 1];
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean} true if the current block is the last command of a loop
|
|
*/
|
|
isLastBlockInLoop () {
|
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
const frame = this.frames[i];
|
|
if (!frame.isLastBlock) {
|
|
return false;
|
|
}
|
|
if (frame.isLoop) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @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_UNKNOWN);
|
|
|
|
case 'compat':
|
|
// Compatibility layer inputs never use flags.
|
|
return new TypedInput(`(${this.generateCompatibilityLayerCall(node, false)})`, TYPE_UNKNOWN);
|
|
|
|
case 'constant':
|
|
return this.safeConstantInput(node.value);
|
|
|
|
case 'keyboard.pressed':
|
|
return new TypedInput(`runtime.ioDevices.keyboard.getKeyIsDown(${this.descendInput(node.key).asSafe()})`, 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 (environment.supportsNullishCoalescing) {
|
|
if (index.isAlwaysNumberOrNaN()) {
|
|
return new TypedInput(`(${this.referenceVariable(node.list)}.value[(${index.asNumber()} | 0) - 1] ?? "")`, TYPE_UNKNOWN);
|
|
}
|
|
if (index instanceof ConstantInput && index.constantValue === 'last') {
|
|
return new TypedInput(`(${this.referenceVariable(node.list)}.value[${this.referenceVariable(node.list)}.value.length - 1] ?? "")`, 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('Math.round(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('limitPrecision(target.x)', TYPE_NUMBER);
|
|
case 'motion.y':
|
|
return new TypedInput('limitPrecision(target.y)', TYPE_NUMBER);
|
|
|
|
case 'mouse.down':
|
|
return new TypedInput('runtime.ioDevices.mouse.getIsDown()', TYPE_BOOLEAN);
|
|
case 'mouse.x':
|
|
return new TypedInput('runtime.ioDevices.mouse.getScratchX()', TYPE_NUMBER);
|
|
case 'mouse.y':
|
|
return new TypedInput('runtime.ioDevices.mouse.getScratchY()', TYPE_NUMBER);
|
|
|
|
case 'op.abs':
|
|
return new TypedInput(`Math.abs(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
case 'op.acos':
|
|
// Needs to be marked as NaN because Math.acos(1.0001) === NaN
|
|
return new TypedInput(`((Math.acos(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER_NAN);
|
|
case 'op.add':
|
|
// Needs to be marked as NaN because Infinity + -Infinity === NaN
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} + ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.and':
|
|
return new TypedInput(`(${this.descendInput(node.left).asBoolean()} && ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN);
|
|
case 'op.asin':
|
|
// Needs to be marked as NaN because Math.asin(1.0001) === NaN
|
|
return new TypedInput(`((Math.asin(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER_NAN);
|
|
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_NAN);
|
|
case 'op.divide':
|
|
// Needs to be marked as NaN because 0 / 0 === NaN
|
|
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);
|
|
// When both operands are known to never be numbers, only use string comparison to avoid all number parsing.
|
|
if (left.isNeverNumber() || right.isNeverNumber()) {
|
|
return new TypedInput(`(${left.asString()}.toLowerCase() === ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN);
|
|
}
|
|
const leftAlwaysNumber = left.isAlwaysNumber();
|
|
const rightAlwaysNumber = right.isAlwaysNumber();
|
|
// When both operands are known to be numbers, we can use ===
|
|
if (leftAlwaysNumber && rightAlwaysNumber) {
|
|
return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
// In certain conditions, we can use === when one of the operands is known to be a safe number.
|
|
if (leftAlwaysNumber && left instanceof ConstantInput && isSafeConstantForEqualsOptimization(left)) {
|
|
return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
if (rightAlwaysNumber && right instanceof ConstantInput && isSafeConstantForEqualsOptimization(right)) {
|
|
return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
// No compile-time optimizations possible - use fallback method.
|
|
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);
|
|
// When the left operand is a number and the right operand is a number or NaN, we can use >
|
|
if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) {
|
|
return new TypedInput(`(${left.asNumber()} > ${right.asNumberOrNaN()})`, TYPE_BOOLEAN);
|
|
}
|
|
// When the left operand is a number or NaN and the right operand is a number, we can negate <=
|
|
if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) {
|
|
return new TypedInput(`!(${left.asNumberOrNaN()} <= ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
// When either operand is known to never be a number, avoid all number parsing.
|
|
if (left.isNeverNumber() || right.isNeverNumber()) {
|
|
return new TypedInput(`(${left.asString()}.toLowerCase() > ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN);
|
|
}
|
|
// No compile-time optimizations possible - use fallback method.
|
|
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);
|
|
// When the left operand is a number or NaN and the right operand is a number, we can use <
|
|
if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) {
|
|
return new TypedInput(`(${left.asNumberOrNaN()} < ${right.asNumber()})`, TYPE_BOOLEAN);
|
|
}
|
|
// When the left operand is a number and the right operand is a number or NaN, we can negate >=
|
|
if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) {
|
|
return new TypedInput(`!(${left.asNumber()} >= ${right.asNumberOrNaN()})`, TYPE_BOOLEAN);
|
|
}
|
|
// When either operand is known to never be a number, avoid all number parsing.
|
|
if (left.isNeverNumber() || right.isNeverNumber()) {
|
|
return new TypedInput(`(${left.asString()}.toLowerCase() < ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN);
|
|
}
|
|
// No compile-time optimizations possible - use fallback method.
|
|
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':
|
|
// Needs to be marked as NaN because Math.log(-1) == NaN
|
|
return new TypedInput(`Math.log(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.log':
|
|
// Needs to be marked as NaN because Math.log(-1) == NaN
|
|
return new TypedInput(`(Math.log(${this.descendInput(node.value).asNumber()}) / Math.LN10)`, TYPE_NUMBER_NAN);
|
|
case 'op.mod':
|
|
this.descendedIntoModulo = true;
|
|
// Needs to be marked as NaN because mod(0, 0) (and others) == NaN
|
|
return new TypedInput(`mod(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.multiply':
|
|
// Needs to be marked as NaN because Infinity * 0 === NaN
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} * ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN);
|
|
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) {
|
|
// Both inputs are ints, so we know neither are NaN
|
|
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_NAN);
|
|
}
|
|
return new TypedInput(`runtime.ext_scratch3_operators._random(${this.descendInput(node.low).asUnknown()}, ${this.descendInput(node.high).asUnknown()})`, TYPE_NUMBER_NAN);
|
|
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_NAN);
|
|
case 'op.sqrt':
|
|
// Needs to be marked as NaN because Math.sqrt(-1) === NaN
|
|
return new TypedInput(`Math.sqrt(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.subtract':
|
|
// Needs to be marked as NaN because Infinity - Infinity === NaN
|
|
return new TypedInput(`(${this.descendInput(node.left).asNumber()} - ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.tan':
|
|
return new TypedInput(`tan(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN);
|
|
case 'op.10^':
|
|
return new TypedInput(`(10 ** ${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER);
|
|
|
|
case 'procedures.call': {
|
|
const procedureCode = node.code;
|
|
const procedureVariant = node.variant;
|
|
const procedureData = this.ir.procedures[procedureVariant];
|
|
if (procedureData.stack === null) {
|
|
// TODO still need to evaluate arguments for side effects
|
|
return new TypedInput('""', TYPE_STRING);
|
|
}
|
|
|
|
// Recursion makes this complicated because:
|
|
// - We need to yield *between* each call in the same command block
|
|
// - We need to evaluate arguments *before* that yield happens
|
|
|
|
const procedureReference = `thread.procedures["${sanitize(procedureVariant)}"]`;
|
|
const args = [];
|
|
for (const input of node.arguments) {
|
|
args.push(this.descendInput(input).asSafe());
|
|
}
|
|
const joinedArgs = args.join(',');
|
|
|
|
const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode;
|
|
if (yieldForRecursion) {
|
|
const runtimeFunction = procedureData.yields ? 'yieldThenCallGenerator' : 'yieldThenCall';
|
|
return new TypedInput(`(yield* ${runtimeFunction}(${procedureReference}, ${joinedArgs}))`, TYPE_UNKNOWN);
|
|
}
|
|
if (procedureData.yields) {
|
|
return new TypedInput(`(yield* ${procedureReference}(${joinedArgs}))`, TYPE_UNKNOWN);
|
|
}
|
|
return new TypedInput(`${procedureReference}(${joinedArgs})`, TYPE_UNKNOWN);
|
|
}
|
|
|
|
case 'sensing.answer':
|
|
return new TypedInput(`runtime.ext_scratch3_sensing._answer`, TYPE_STRING);
|
|
case 'sensing.colorTouchingColor':
|
|
return new TypedInput(`target.colorIsTouchingColor(colorToList(${this.descendInput(node.target).asColor()}), colorToList(${this.descendInput(node.mask).asColor()}))`, TYPE_BOOLEAN);
|
|
case 'sensing.date':
|
|
return new TypedInput(`(new Date().getDate())`, TYPE_NUMBER);
|
|
case 'sensing.dayofweek':
|
|
return new TypedInput(`(new Date().getDay() + 1)`, TYPE_NUMBER);
|
|
case 'sensing.daysSince2000':
|
|
return new TypedInput('daysSince2000()', TYPE_NUMBER);
|
|
case 'sensing.distance':
|
|
// TODO: on stages, this can be computed at compile time
|
|
return new TypedInput(`distance(${this.descendInput(node.target).asString()})`, TYPE_NUMBER);
|
|
case 'sensing.hour':
|
|
return new TypedInput(`(new Date().getHours())`, TYPE_NUMBER);
|
|
case 'sensing.minute':
|
|
return new TypedInput(`(new Date().getMinutes())`, TYPE_NUMBER);
|
|
case 'sensing.month':
|
|
return new TypedInput(`(new Date().getMonth() + 1)`, TYPE_NUMBER);
|
|
case 'sensing.of': {
|
|
const object = this.descendInput(node.object).asString();
|
|
const property = node.property;
|
|
if (node.object.kind === 'constant') {
|
|
const isStage = node.object.value === '_stage_';
|
|
// Note that if target isn't a stage, we can't assume it exists
|
|
const objectReference = isStage ? 'stage' : this.evaluateOnce(`runtime.getSpriteTargetByName(${object})`);
|
|
if (property === 'volume') {
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.volume : 0)`, TYPE_NUMBER);
|
|
}
|
|
if (isStage) {
|
|
switch (property) {
|
|
case 'background #':
|
|
// fallthrough for scratch 1.0 compatibility
|
|
case 'backdrop #':
|
|
return new TypedInput(`(${objectReference}.currentCostume + 1)`, TYPE_NUMBER);
|
|
case 'backdrop name':
|
|
return new TypedInput(`${objectReference}.getCostumes()[${objectReference}.currentCostume].name`, TYPE_STRING);
|
|
}
|
|
} else {
|
|
switch (property) {
|
|
case 'x position':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.x : 0)`, TYPE_NUMBER);
|
|
case 'y position':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.y : 0)`, TYPE_NUMBER);
|
|
case 'direction':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.direction : 0)`, TYPE_NUMBER);
|
|
case 'costume #':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.currentCostume + 1 : 0)`, TYPE_NUMBER);
|
|
case 'costume name':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.getCostumes()[${objectReference}.currentCostume].name : 0)`, TYPE_UNKNOWN);
|
|
case 'size':
|
|
return new TypedInput(`(${objectReference} ? ${objectReference}.size : 0)`, TYPE_NUMBER);
|
|
}
|
|
}
|
|
const variableReference = this.evaluateOnce(`${objectReference} && ${objectReference}.lookupVariableByNameAndType("${sanitize(property)}", "", true)`);
|
|
return new TypedInput(`(${variableReference} ? ${variableReference}.value : 0)`, TYPE_UNKNOWN);
|
|
}
|
|
return new TypedInput(`runtime.ext_scratch3_sensing.getAttributeOf({OBJECT: ${object}, PROPERTY: "${sanitize(property)}" })`, TYPE_UNKNOWN);
|
|
}
|
|
case 'sensing.second':
|
|
return new TypedInput(`(new Date().getSeconds())`, TYPE_NUMBER);
|
|
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).asColor()}))`, TYPE_BOOLEAN);
|
|
case 'sensing.username':
|
|
return new TypedInput('runtime.ioDevices.userData.getUsername()', TYPE_STRING);
|
|
case 'sensing.year':
|
|
return new TypedInput(`(new Date().getFullYear())`, TYPE_NUMBER);
|
|
|
|
case 'timer.get':
|
|
return new TypedInput('runtime.ioDevices.clock.projectTimer()', TYPE_NUMBER);
|
|
|
|
case 'tw.lastKeyPressed':
|
|
return new TypedInput('runtime.ioDevices.keyboard.getLastKeyPressed()', TYPE_STRING);
|
|
|
|
case 'var.get':
|
|
return this.descendVariable(node.variable);
|
|
|
|
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 'addons.call': {
|
|
const inputs = this.descendInputRecord(node.arguments);
|
|
const blockFunction = `runtime.getAddonBlock("${sanitize(node.code)}").callback`;
|
|
const blockId = `"${sanitize(node.blockId)}"`;
|
|
this.source += `yield* executeInCompatibilityLayer(${inputs}, ${blockFunction}, ${this.isWarp}, false, ${blockId});\n`;
|
|
break;
|
|
}
|
|
|
|
case 'compat': {
|
|
// If the last command in a loop returns a promise, immediately continue to the next iteration.
|
|
// If you don't do this, the loop effectively yields twice per iteration and will run at half-speed.
|
|
const isLastInLoop = this.isLastBlockInLoop();
|
|
this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`;
|
|
if (isLastInLoop) {
|
|
this.source += 'if (hasResumedFromPromise) {hasResumedFromPromise = false;continue;}\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': {
|
|
this.resetVariableInputs();
|
|
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, new Frame(true));
|
|
this.yieldLoop();
|
|
this.source += '}\n';
|
|
break;
|
|
}
|
|
case 'control.if':
|
|
this.source += `if (${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.descendStack(node.whenTrue, new Frame(false));
|
|
// 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, new Frame(false));
|
|
}
|
|
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, new Frame(true));
|
|
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':
|
|
this.stopScript();
|
|
break;
|
|
case 'control.wait': {
|
|
const duration = this.localVariables.next();
|
|
this.source += `thread.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 (thread.timer.timeElapsed() < ${duration}) {\n`;
|
|
this.yieldStuckOrNotWarp();
|
|
this.source += '}\n';
|
|
this.source += 'thread.timer = null;\n';
|
|
break;
|
|
}
|
|
case 'control.waitUntil': {
|
|
this.resetVariableInputs();
|
|
this.source += `while (!${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.yieldStuckOrNotWarp();
|
|
this.source += `}\n`;
|
|
break;
|
|
}
|
|
case 'control.while':
|
|
this.resetVariableInputs();
|
|
this.source += `while (${this.descendInput(node.condition).asBoolean()}) {\n`;
|
|
this.descendStack(node.do, new Frame(true));
|
|
if (node.warpTimer) {
|
|
this.yieldStuckOrNotWarp();
|
|
} else {
|
|
this.yieldLoop();
|
|
}
|
|
this.source += `}\n`;
|
|
break;
|
|
|
|
case 'event.broadcast':
|
|
this.source += `startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });\n`;
|
|
this.resetVariableInputs();
|
|
break;
|
|
case 'event.broadcastAndWait':
|
|
this.source += `yield* waitThreads(startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} }));\n`;
|
|
this.yielded();
|
|
break;
|
|
|
|
case 'list.add': {
|
|
const list = this.referenceVariable(node.list);
|
|
this.source += `${list}.value.push(${this.descendInput(node.item).asSafe()});\n`;
|
|
this.source += `${list}._monitorUpToDate = false;\n`;
|
|
break;
|
|
}
|
|
case 'list.delete': {
|
|
const list = this.referenceVariable(node.list);
|
|
const index = this.descendInput(node.index);
|
|
if (index instanceof ConstantInput) {
|
|
if (index.constantValue === 'last') {
|
|
this.source += `${list}.value.pop();\n`;
|
|
this.source += `${list}._monitorUpToDate = false;\n`;
|
|
break;
|
|
}
|
|
if (+index.constantValue === 1) {
|
|
this.source += `${list}.value.shift();\n`;
|
|
this.source += `${list}._monitorUpToDate = false;\n`;
|
|
break;
|
|
}
|
|
// do not need a special case for all as that is handled in IR generation (list.deleteAll)
|
|
}
|
|
this.source += `listDelete(${list}, ${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': {
|
|
const list = this.referenceVariable(node.list);
|
|
const index = this.descendInput(node.index);
|
|
const item = this.descendInput(node.item);
|
|
if (index instanceof ConstantInput && +index.constantValue === 1) {
|
|
this.source += `${list}.value.unshift(${item.asSafe()});\n`;
|
|
this.source += `${list}._monitorUpToDate = false;\n`;
|
|
break;
|
|
}
|
|
this.source += `listInsert(${list}, ${index.asUnknown()}, ${item.asSafe()});\n`;
|
|
break;
|
|
}
|
|
case 'list.replace':
|
|
this.source += `listReplace(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asSafe()});\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':
|
|
if (!this.target.isStage) {
|
|
this.source += `target.goBackwardLayers(${this.descendInput(node.layers).asNumber()});\n`;
|
|
}
|
|
break;
|
|
case 'looks.clearEffects':
|
|
this.source += 'target.clearEffects();\n';
|
|
break;
|
|
case 'looks.changeEffect':
|
|
if (this.target.effects.hasOwnProperty(node.effect)) {
|
|
this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()} + target.effects["${sanitize(node.effect)}"]));\n`;
|
|
}
|
|
break;
|
|
case 'looks.changeSize':
|
|
this.source += `target.setSize(target.size + ${this.descendInput(node.size).asNumber()});\n`;
|
|
break;
|
|
case 'looks.forwardLayers':
|
|
if (!this.target.isStage) {
|
|
this.source += `target.goForwardLayers(${this.descendInput(node.layers).asNumber()});\n`;
|
|
}
|
|
break;
|
|
case 'looks.goToBack':
|
|
if (!this.target.isStage) {
|
|
this.source += 'target.goToBack();\n';
|
|
}
|
|
break;
|
|
case 'looks.goToFront':
|
|
if (!this.target.isStage) {
|
|
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.nextBackdrop':
|
|
this.source += 'runtime.ext_scratch3_looks._setBackdrop(stage, stage.currentCostume + 1, true);\n';
|
|
break;
|
|
case 'looks.nextCostume':
|
|
this.source += 'target.setCostume(target.currentCostume + 1);\n';
|
|
break;
|
|
case 'looks.setEffect':
|
|
if (this.target.effects.hasOwnProperty(node.effect)) {
|
|
this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()}));\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).asSafe()});\n`;
|
|
break;
|
|
case 'looks.switchCostume':
|
|
this.source += `runtime.ext_scratch3_looks._setCostume(target, ${this.descendInput(node.costume).asSafe()});\n`;
|
|
break;
|
|
|
|
case 'motion.changeX':
|
|
this.source += `target.setXY(target.x + ${this.descendInput(node.dx).asNumber()}, target.y);\n`;
|
|
break;
|
|
case 'motion.changeY':
|
|
this.source += `target.setXY(target.x, target.y + ${this.descendInput(node.dy).asNumber()});\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.setX': // fallthrough
|
|
case 'motion.setY': // fallthrough
|
|
case 'motion.setXY': {
|
|
this.descendedIntoModulo = false;
|
|
const x = 'x' in node ? this.descendInput(node.x).asNumber() : 'target.x';
|
|
const y = 'y' in node ? this.descendInput(node.y).asNumber() : 'target.y';
|
|
this.source += `target.setXY(${x}, ${y});\n`;
|
|
if (this.descendedIntoModulo) {
|
|
this.source += `if (target.interpolationData) target.interpolationData = null;\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_EXT}.clear();\n`;
|
|
break;
|
|
case 'pen.down':
|
|
this.source += `${PEN_EXT}._penDown(target);\n`;
|
|
break;
|
|
case 'pen.changeParam':
|
|
this.source += `${PEN_EXT}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${PEN_STATE}, true);\n`;
|
|
break;
|
|
case 'pen.changeSize':
|
|
this.source += `${PEN_EXT}._changePenSizeBy(${this.descendInput(node.size).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacyChangeHue':
|
|
this.source += `${PEN_EXT}._changePenHueBy(${this.descendInput(node.hue).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacyChangeShade':
|
|
this.source += `${PEN_EXT}._changePenShadeBy(${this.descendInput(node.shade).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacySetHue':
|
|
this.source += `${PEN_EXT}._setPenHueToNumber(${this.descendInput(node.hue).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.legacySetShade':
|
|
this.source += `${PEN_EXT}._setPenShadeToNumber(${this.descendInput(node.shade).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.setColor':
|
|
this.source += `${PEN_EXT}._setPenColorToColor(${this.descendInput(node.color).asColor()}, target);\n`;
|
|
break;
|
|
case 'pen.setParam':
|
|
this.source += `${PEN_EXT}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${PEN_STATE}, false);\n`;
|
|
break;
|
|
case 'pen.setSize':
|
|
this.source += `${PEN_EXT}._setPenSizeTo(${this.descendInput(node.size).asNumber()}, target);\n`;
|
|
break;
|
|
case 'pen.stamp':
|
|
this.source += `${PEN_EXT}._stamp(target);\n`;
|
|
break;
|
|
case 'pen.up':
|
|
this.source += `${PEN_EXT}._penUp(target);\n`;
|
|
break;
|
|
|
|
case 'procedures.call': {
|
|
const procedureCode = node.code;
|
|
const procedureVariant = node.variant;
|
|
const procedureData = this.ir.procedures[procedureVariant];
|
|
if (procedureData.stack === null) {
|
|
// TODO still need to evaluate arguments
|
|
break;
|
|
}
|
|
|
|
const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode;
|
|
if (yieldForRecursion) {
|
|
this.yieldNotWarp();
|
|
}
|
|
|
|
if (procedureData.yields) {
|
|
this.source += 'yield* ';
|
|
}
|
|
this.source += `thread.procedures["${sanitize(procedureVariant)}"](`;
|
|
const args = [];
|
|
for (const input of node.arguments) {
|
|
args.push(this.descendInput(input).asSafe());
|
|
}
|
|
this.source += args.join(',');
|
|
this.source += ');\n';
|
|
|
|
this.resetVariableInputs();
|
|
break;
|
|
}
|
|
case 'procedures.return':
|
|
this.stopScriptAndReturn(this.descendInput(node.value).asSafe());
|
|
break;
|
|
|
|
case 'timer.reset':
|
|
this.source += 'runtime.ioDevices.clock.resetProjectTimer();\n';
|
|
break;
|
|
|
|
case 'tw.debugger':
|
|
this.source += 'debugger;\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.descendVariable(node.variable);
|
|
const value = this.descendInput(node.value);
|
|
variable.setInput(value);
|
|
this.source += `${variable.source} = ${value.asSafe()};\n`;
|
|
if (node.variable.isCloud) {
|
|
this.source += `runtime.ioDevices.cloud.requestUpdateVariable("${sanitize(node.variable.name)}", ${variable.source});\n`;
|
|
}
|
|
break;
|
|
}
|
|
case 'var.show':
|
|
this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: true }, runtime);\n`;
|
|
break;
|
|
|
|
case 'visualReport': {
|
|
const value = this.localVariables.next();
|
|
this.source += `const ${value} = ${this.descendInput(node.input).asUnknown()};`;
|
|
// blocks like legacy no-ops can return a literal `undefined`
|
|
this.source += `if (${value} !== undefined) runtime.visualReport("${sanitize(this.script.topBlockId)}", ${value});\n`;
|
|
break;
|
|
}
|
|
|
|
default:
|
|
log.warn(`JS: Unknown stacked block: ${node.kind}`, node);
|
|
throw new Error(`JS: Unknown stacked block: ${node.kind}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile a Record of input objects into a safe JS string.
|
|
* @param {Record<string, unknown>} inputs
|
|
* @returns {string}
|
|
*/
|
|
descendInputRecord (inputs) {
|
|
let result = '{';
|
|
for (const name of Object.keys(inputs)) {
|
|
const node = inputs[name];
|
|
result += `"${sanitize(name)}":${this.descendInput(node).asSafe()},`;
|
|
}
|
|
result += '}';
|
|
return result;
|
|
}
|
|
|
|
resetVariableInputs () {
|
|
this.variableInputs = {};
|
|
}
|
|
|
|
descendStack (nodes, frame) {
|
|
// Entering a stack -- all bets are off.
|
|
// TODO: allow if/else to inherit values
|
|
this.resetVariableInputs();
|
|
this.pushFrame(frame);
|
|
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
frame.isLastBlock = i === nodes.length - 1;
|
|
this.descendStackedBlock(nodes[i]);
|
|
}
|
|
|
|
// Leaving a stack -- any assumptions made in the current stack do not apply outside of it
|
|
// TODO: in if/else this might create an extra unused object
|
|
this.resetVariableInputs();
|
|
this.popFrame();
|
|
}
|
|
|
|
descendVariable (variable) {
|
|
if (this.variableInputs.hasOwnProperty(variable.id)) {
|
|
return this.variableInputs[variable.id];
|
|
}
|
|
const input = new VariableInput(`${this.referenceVariable(variable)}.value`);
|
|
this.variableInputs[variable.id] = input;
|
|
return input;
|
|
}
|
|
|
|
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() (sets thread status and cleans up some unused data), 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 sequencer.
|
|
// Outside of a procedure, return will correctly bring us back to the sequencer.
|
|
if (this.isProcedure) {
|
|
this.source += 'retire(); yield;\n';
|
|
} else {
|
|
this.source += 'retire(); return;\n';
|
|
}
|
|
}
|
|
|
|
stopScript () {
|
|
if (this.isProcedure) {
|
|
this.source += 'return "";\n';
|
|
} else {
|
|
this.retire();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} valueJS JS code of value to return.
|
|
*/
|
|
stopScriptAndReturn (valueJS) {
|
|
if (this.isProcedure) {
|
|
this.source += `return ${valueJS};\n`;
|
|
} else {
|
|
this.retire();
|
|
}
|
|
}
|
|
|
|
yieldLoop () {
|
|
if (this.warpTimer) {
|
|
this.yieldStuckOrNotWarp();
|
|
} else {
|
|
this.yieldNotWarp();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write JS to yield the current thread if warp mode is disabled.
|
|
*/
|
|
yieldNotWarp () {
|
|
if (!this.isWarp) {
|
|
this.source += 'yield;\n';
|
|
this.yielded();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write JS to yield the current thread if warp mode is disabled or if the script seems to be stuck.
|
|
*/
|
|
yieldStuckOrNotWarp () {
|
|
if (this.isWarp) {
|
|
this.source += 'if (isStuck()) yield;\n';
|
|
} else {
|
|
this.source += 'yield;\n';
|
|
}
|
|
this.yielded();
|
|
}
|
|
|
|
yielded () {
|
|
if (!this.script.yields) {
|
|
throw new Error('Script yielded but is not marked as yielding.');
|
|
}
|
|
// Control may have been yielded to another script -- all bets are off.
|
|
this.resetVariableInputs();
|
|
}
|
|
|
|
/**
|
|
* Write JS to request a redraw.
|
|
*/
|
|
requestRedraw () {
|
|
this.source += 'runtime.requestRedraw();\n';
|
|
}
|
|
|
|
safeConstantInput (value) {
|
|
const unsafe = typeof value === 'string' && this.namesOfCostumesAndSounds.has(value);
|
|
return new ConstantInput(value, !unsafe);
|
|
}
|
|
|
|
/**
|
|
* Generate a call into the compatibility layer.
|
|
* @param {*} node The "compat" kind node to generate from.
|
|
* @param {boolean} setFlags Whether flags should be set describing how this function was processed.
|
|
* @returns {string} The JS of the call.
|
|
*/
|
|
generateCompatibilityLayerCall (node, setFlags) {
|
|
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).asSafe();
|
|
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}, ${this.isWarp}, ${setFlags}, null)`;
|
|
|
|
return result;
|
|
}
|
|
|
|
getScriptFactoryName () {
|
|
return factoryNameVariablePool.next();
|
|
}
|
|
|
|
getScriptName (yields) {
|
|
let name = yields ? generatorNameVariablePool.next() : functionNameVariablePool.next();
|
|
if (this.isProcedure) {
|
|
const simplifiedProcedureCode = this.script.procedureCode
|
|
.replace(/%[\w]/g, '') // remove arguments
|
|
.replace(/[^a-zA-Z0-9]/g, '_') // remove unsafe
|
|
.substring(0, 20); // keep length reasonable
|
|
name += `_${simplifiedProcedureCode}`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* 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 ${this.getScriptFactoryName()}(thread) { `;
|
|
script += 'const target = thread.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* `;
|
|
} else {
|
|
script += `function `;
|
|
}
|
|
script += this.getScriptName(this.script.yields);
|
|
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;
|
|
|
|
script += '}; })';
|
|
|
|
return script;
|
|
}
|
|
|
|
/**
|
|
* Compile this script.
|
|
* @returns {Function} The factory function for the script.
|
|
*/
|
|
compile () {
|
|
if (this.script.stack) {
|
|
this.descendStack(this.script.stack, new Frame(false));
|
|
}
|
|
this.stopScript();
|
|
|
|
const factory = this.createScriptFactory();
|
|
const fn = jsexecute.scopedEval(factory);
|
|
|
|
if (this.debug) {
|
|
log.info(`JS: ${this.target.getName()}: compiled ${this.script.procedureCode || 'script'}`, factory);
|
|
}
|
|
|
|
if (JSGenerator.testingApparatus) {
|
|
JSGenerator.testingApparatus.report(this, factory);
|
|
}
|
|
|
|
return fn;
|
|
}
|
|
}
|
|
|
|
// Test hook used by automated snapshot testing.
|
|
JSGenerator.testingApparatus = null;
|
|
|
|
module.exports = JSGenerator;
|