@triflare/fx 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +73 -2
- package/bin/fx.js +949 -0
- package/package.json +64 -4
- package/product.yml +28 -0
- package/src/brand.js +105 -0
- package/src/build.js +181 -0
- package/src/catalog.js +753 -0
- package/src/compiler.js +1658 -0
- package/src/config-edit.js +338 -0
- package/src/config.js +400 -0
- package/src/decompiler.js +504 -0
- package/src/dev.js +390 -0
- package/src/errors.js +56 -0
- package/src/extensions.js +707 -0
- package/src/index.js +31 -0
- package/src/language.js +12 -0
- package/src/lexer.js +202 -0
- package/src/log.js +70 -0
- package/src/package.js +202 -0
- package/src/packager.js +177 -0
- package/src/parser.js +608 -0
- package/src/product.js +27 -0
- package/src/project.js +156 -0
- package/src/sb3.js +340 -0
- package/src/sprite.js +81 -0
- package/vendor/pm-packager-dist/.fx-build.json +6 -0
- package/vendor/pm-packager-dist/LICENSE +202 -0
- package/vendor/pm-packager-dist/NOTICE +6 -0
- package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
- package/vendor/pm-packager-dist/dist/icns.js +1 -0
- package/vendor/pm-packager-dist/dist/packager.js +154 -0
- package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
- package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
- package/vendor/pm-packager-dist/package.json +3 -0
package/src/compiler.js
ADDED
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triflare Fx compiler: AST -> Scratch 3 / PenguinMod project JSON.
|
|
3
|
+
*
|
|
4
|
+
* The compiler is deliberately catalog-driven: every `category.block(...)` is
|
|
5
|
+
* resolved through `catalog.js`, so adding a new block is a data change rather
|
|
6
|
+
* than a code change.
|
|
7
|
+
*/
|
|
8
|
+
import { CATALOG, LIST_OPS, extensionForOpcode } from "./catalog.js";
|
|
9
|
+
import { CompileError } from "./errors.js";
|
|
10
|
+
import { configComment } from "./config.js";
|
|
11
|
+
import { md5hex, imageSize, wavInfo, detectFormat } from "./sb3.js";
|
|
12
|
+
|
|
13
|
+
const HAT_ALIASES = {
|
|
14
|
+
greenFlag: "events.greenFlag",
|
|
15
|
+
keyPressed: "events.keyPressed",
|
|
16
|
+
spriteClicked: "events.spriteClicked",
|
|
17
|
+
thisSpriteClicked: "events.spriteClicked",
|
|
18
|
+
stageClicked: "events.stageClicked",
|
|
19
|
+
cloneStart: "events.cloneStart",
|
|
20
|
+
startAsClone: "events.cloneStart",
|
|
21
|
+
broadcast: "events.broadcastReceived",
|
|
22
|
+
broadcastReceived: "events.broadcastReceived",
|
|
23
|
+
backdropSwitchesTo: "events.backdropSwitchesTo",
|
|
24
|
+
loudnessGreaterThan: "events.loudnessGreaterThan",
|
|
25
|
+
timerGreaterThan: "events.timerGreaterThan",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const NUMBER_TYPES = {
|
|
29
|
+
number: 4,
|
|
30
|
+
positive: 5,
|
|
31
|
+
whole: 6,
|
|
32
|
+
integer: 7,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Opcodes whose block ends the stack: scratch-blocks gives them "shape_end"
|
|
36
|
+
// (no next-statement connection), so nothing can be serialized below them. The
|
|
37
|
+
// editor dereferences that missing connection while reading a following
|
|
38
|
+
// `<next>` element, which aborts the whole workspace update — so the compiler
|
|
39
|
+
// must refuse to emit statements after one.
|
|
40
|
+
const TERMINAL_OPCODES = new Set([
|
|
41
|
+
"procedures_return",
|
|
42
|
+
"control_delete_this_clone",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
let seed = 0n;
|
|
46
|
+
function uid(prefix = "b") {
|
|
47
|
+
seed += 1n;
|
|
48
|
+
const rand = Math.floor(Math.random() * 0x1000000)
|
|
49
|
+
.toString(16)
|
|
50
|
+
.padStart(6, "0");
|
|
51
|
+
return `${prefix}_${seed.toString(16)}_${rand}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {unknown} value */
|
|
55
|
+
function normalizeColor(value) {
|
|
56
|
+
const s = String(value).trim().toLowerCase();
|
|
57
|
+
if (/^#[0-9a-f]{6}$/.test(s)) return s;
|
|
58
|
+
if (/^#[0-9a-f]{3}$/.test(s))
|
|
59
|
+
return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
|
|
60
|
+
if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`;
|
|
61
|
+
return "#000000";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function blankSvg(width, height, fill = false) {
|
|
65
|
+
const rect = fill
|
|
66
|
+
? `<rect width="${width}" height="${height}" fill="#ffffff"/>`
|
|
67
|
+
: "";
|
|
68
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rect}</svg>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class Context {
|
|
72
|
+
constructor(compiler, target, isStage) {
|
|
73
|
+
this.compiler = compiler;
|
|
74
|
+
this.target = target;
|
|
75
|
+
this.isStage = isStage;
|
|
76
|
+
this.vars = new Map();
|
|
77
|
+
this.lists = new Map();
|
|
78
|
+
this.funcs = new Map();
|
|
79
|
+
this.paramScopes = [];
|
|
80
|
+
this.scriptX = 48;
|
|
81
|
+
this.scriptY = 48;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
nextScriptPosition() {
|
|
85
|
+
const x = this.scriptX;
|
|
86
|
+
const y = this.scriptY;
|
|
87
|
+
this.scriptY += 420;
|
|
88
|
+
if (this.scriptY > 3600) {
|
|
89
|
+
this.scriptY = 48;
|
|
90
|
+
this.scriptX += 500;
|
|
91
|
+
}
|
|
92
|
+
return { x, y };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
resolveValue(name) {
|
|
96
|
+
for (let i = this.paramScopes.length - 1; i >= 0; i--) {
|
|
97
|
+
const scope = this.paramScopes[i];
|
|
98
|
+
if (scope.has(name)) return { kind: "param", param: scope.get(name) };
|
|
99
|
+
}
|
|
100
|
+
if (this.vars.has(name)) return { kind: "var", ...this.vars.get(name) };
|
|
101
|
+
if (this.lists.has(name)) return { kind: "list", ...this.lists.get(name) };
|
|
102
|
+
const global = this.compiler.globals.get(name);
|
|
103
|
+
if (global) return { kind: global.kind, ...global };
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
resolveList(name) {
|
|
108
|
+
if (this.lists.has(name)) return this.lists.get(name);
|
|
109
|
+
const global = this.compiler.globals.get(name);
|
|
110
|
+
if (global && global.kind === "list") return global;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class Compiler {
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} options
|
|
118
|
+
* @param {object} options.project normalized fx.yml project config
|
|
119
|
+
* @param {(p: string) => Buffer} [options.loadAsset]
|
|
120
|
+
* @param {Record<string, object>} [options.catalog] extra/overriding catalog entries
|
|
121
|
+
* @param {Record<string, string>} [options.extensionUrls] id -> URL
|
|
122
|
+
* @param {string} [options.file]
|
|
123
|
+
*/
|
|
124
|
+
constructor({
|
|
125
|
+
project,
|
|
126
|
+
loadAsset,
|
|
127
|
+
catalog,
|
|
128
|
+
extensionUrls = {},
|
|
129
|
+
file = "<input>",
|
|
130
|
+
} = {}) {
|
|
131
|
+
this.projectConfig = project || {};
|
|
132
|
+
this.loadAsset = loadAsset || null;
|
|
133
|
+
this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
|
|
134
|
+
this.extensionUrls = extensionUrls;
|
|
135
|
+
this.file = file;
|
|
136
|
+
|
|
137
|
+
// opcode -> extension id, derived from the (merged) catalog. This lets
|
|
138
|
+
// synthesized extension blocks declare an id that isn't inferable from
|
|
139
|
+
// the opcode prefix alone.
|
|
140
|
+
this.opcodeExtensions = new Map();
|
|
141
|
+
for (const entry of Object.values(this.catalog)) {
|
|
142
|
+
if (entry && entry.extension && entry.opcode)
|
|
143
|
+
this.opcodeExtensions.set(entry.opcode, entry.extension);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Opcodes whose next-statement connection only exists once a mutation asks
|
|
147
|
+
// for it (PenguinMod's loop nub), derived from the merged catalog.
|
|
148
|
+
this.dynamicNextOpcodes = new Set();
|
|
149
|
+
for (const entry of Object.values(this.catalog)) {
|
|
150
|
+
if (entry && entry.dynamicNext && entry.opcode)
|
|
151
|
+
this.dynamicNextOpcodes.add(entry.opcode);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.assets = new Map(); // md5ext -> Buffer
|
|
155
|
+
this.globals = new Map(); // name -> {kind:'var'|'list', id, name, cloud?, value?}
|
|
156
|
+
this.broadcasts = new Map(); // name -> id
|
|
157
|
+
this.varCounter = 0;
|
|
158
|
+
this.broadcastCounter = 0;
|
|
159
|
+
|
|
160
|
+
/** @type {Context|null} */
|
|
161
|
+
this.ctx = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ------------------------------------------------------------- utilities
|
|
165
|
+
|
|
166
|
+
fail(message, loc) {
|
|
167
|
+
throw new CompileError(message, {
|
|
168
|
+
file: (loc && loc.file) || this.file,
|
|
169
|
+
line: loc && loc.line,
|
|
170
|
+
column: loc && loc.column,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Human-readable source location for a duplicate declaration. */
|
|
175
|
+
where(loc) {
|
|
176
|
+
if (!loc || !loc.file) return null;
|
|
177
|
+
return loc.line != null ? `${loc.file}:${loc.line}` : loc.file;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
newVarId() {
|
|
181
|
+
return `v${(++this.varCounter).toString(36)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
registerAsset(buffer, format) {
|
|
185
|
+
const assetId = md5hex(buffer);
|
|
186
|
+
const md5ext = `${assetId}.${format}`;
|
|
187
|
+
if (!this.assets.has(md5ext)) this.assets.set(md5ext, buffer);
|
|
188
|
+
return { assetId, md5ext, dataFormat: format };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
declareGlobal(kind, name, extra = {}) {
|
|
192
|
+
const id = this.newVarId();
|
|
193
|
+
const info = { kind, id, name, ...extra };
|
|
194
|
+
this.globals.set(name, info);
|
|
195
|
+
return info;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
broadcast(name) {
|
|
199
|
+
const clean = String(name);
|
|
200
|
+
if (!this.broadcasts.has(clean)) {
|
|
201
|
+
this.broadcasts.set(clean, uid("bc"));
|
|
202
|
+
}
|
|
203
|
+
return this.broadcasts.get(clean);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --------------------------------------------------------------- entry
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {object} ast Program node
|
|
210
|
+
* @returns {{project: object, assets: Map<string, Buffer>}}
|
|
211
|
+
*/
|
|
212
|
+
compile(ast) {
|
|
213
|
+
const stageDecl = ast.stage;
|
|
214
|
+
const stageItems = [...(stageDecl ? stageDecl.body : []), ...ast.topItems];
|
|
215
|
+
|
|
216
|
+
this.stageContext = new Context(this, this.makeTarget(null, true), true);
|
|
217
|
+
|
|
218
|
+
const contexts = [];
|
|
219
|
+
this.stageCtx = this.buildTargetContext(
|
|
220
|
+
this.stageContext,
|
|
221
|
+
stageItems,
|
|
222
|
+
"Stage",
|
|
223
|
+
);
|
|
224
|
+
contexts.push(this.stageCtx);
|
|
225
|
+
|
|
226
|
+
for (const sprite of ast.sprites) {
|
|
227
|
+
const target = this.makeTarget(sprite.name, false);
|
|
228
|
+
const ctx = this.buildTargetContext(
|
|
229
|
+
new Context(this, target, false),
|
|
230
|
+
sprite.body,
|
|
231
|
+
sprite.name,
|
|
232
|
+
);
|
|
233
|
+
ctx.sprite = sprite;
|
|
234
|
+
contexts.push(ctx);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Pass 2: emit scripts (hats + function bodies).
|
|
238
|
+
for (const ctx of contexts) {
|
|
239
|
+
this.ctx = ctx;
|
|
240
|
+
this.emitTargetScripts(ctx);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Pass 3: editor-only connection state. "Next" links are wired up as the
|
|
244
|
+
// stacks are emitted, so blocks whose next-statement connection is dynamic
|
|
245
|
+
// can only be given their mutation once every stack is complete.
|
|
246
|
+
for (const ctx of contexts) this.applyDynamicNextMutations(ctx);
|
|
247
|
+
|
|
248
|
+
return { project: this.assembleProject(contexts), assets: this.assets };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Give every block with a dynamic next-statement connection the mutation the
|
|
253
|
+
* editor itself stores for it.
|
|
254
|
+
*
|
|
255
|
+
* PenguinMod's `control_forever` starts out without a next-statement
|
|
256
|
+
* connection: `Blockly.Blocks.control_forever.init` calls
|
|
257
|
+
* `setNextStatement(false)`, and only `domToMutation` (fed by
|
|
258
|
+
* `<mutation hasbreak="true">`, the nub the editor adds once a block sits
|
|
259
|
+
* below the loop) calls `setNextStatement(true, 'normal')`. A loop with
|
|
260
|
+
* blocks stacked under it but no such mutation makes the editor read
|
|
261
|
+
* `nextConnection.isConnected()` on null while handling the following
|
|
262
|
+
* `<next>` element, which aborts the entire workspace update.
|
|
263
|
+
* @param {Context} ctx
|
|
264
|
+
*/
|
|
265
|
+
applyDynamicNextMutations(ctx) {
|
|
266
|
+
if (this.dynamicNextOpcodes.size === 0) return;
|
|
267
|
+
for (const block of Object.values(ctx.target.blocks)) {
|
|
268
|
+
if (!this.dynamicNextOpcodes.has(block.opcode)) continue;
|
|
269
|
+
block.mutation = {
|
|
270
|
+
tagName: "mutation",
|
|
271
|
+
hasbreak: Boolean(block.next),
|
|
272
|
+
children: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
makeTarget(name, isStage) {
|
|
278
|
+
return {
|
|
279
|
+
isStage,
|
|
280
|
+
name: isStage ? "Stage" : name,
|
|
281
|
+
variables: {},
|
|
282
|
+
lists: {},
|
|
283
|
+
broadcasts: {},
|
|
284
|
+
blocks: {},
|
|
285
|
+
comments: {},
|
|
286
|
+
costumes: [],
|
|
287
|
+
sounds: [],
|
|
288
|
+
volume: 100,
|
|
289
|
+
layerOrder: 0,
|
|
290
|
+
visible: true,
|
|
291
|
+
x: 0,
|
|
292
|
+
y: 0,
|
|
293
|
+
size: 100,
|
|
294
|
+
direction: 90,
|
|
295
|
+
draggable: false,
|
|
296
|
+
rotationStyle: "all around",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
buildTargetContext(ctx, items, targetName) {
|
|
301
|
+
ctx.bodyItems = items;
|
|
302
|
+
ctx.targetName = targetName;
|
|
303
|
+
// ---- pass 1: declarations (hoisted) ------------------------------
|
|
304
|
+
for (const item of items) {
|
|
305
|
+
if (item.type === "PropertyDecl") continue;
|
|
306
|
+
if (item.type === "VarDecl") {
|
|
307
|
+
this.declareTargetVar(ctx, item);
|
|
308
|
+
} else if (item.type === "ListDecl") {
|
|
309
|
+
this.declareTargetList(ctx, item);
|
|
310
|
+
} else if (item.type === "FunctionDecl") {
|
|
311
|
+
this.declareFunction(ctx, item);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- costumes / sounds / layer -----------------------------------
|
|
316
|
+
for (const item of items) {
|
|
317
|
+
if (item.type !== "PropertyDecl") continue;
|
|
318
|
+
if (item.key === "costumes") this.applyCostumes(ctx, item);
|
|
319
|
+
else if (item.key === "sounds") this.applySounds(ctx, item);
|
|
320
|
+
else if (item.key === "layer") {
|
|
321
|
+
const value =
|
|
322
|
+
item.value && item.value.type === "Number" ? item.value.value : 0;
|
|
323
|
+
ctx.target.layerOrder = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return ctx;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
declareTargetVar(ctx, decl) {
|
|
331
|
+
const global = ctx.isStage;
|
|
332
|
+
if (global && this.globals.has(decl.name)) {
|
|
333
|
+
const first = this.globals.get(decl.name);
|
|
334
|
+
const where = this.where(first.loc);
|
|
335
|
+
this.fail(
|
|
336
|
+
`Duplicate global variable ${JSON.stringify(decl.name)}` +
|
|
337
|
+
(where ? ` (already declared in ${where})` : ""),
|
|
338
|
+
decl.loc,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (!global && ctx.vars.has(decl.name)) {
|
|
342
|
+
const first = ctx.vars.get(decl.name);
|
|
343
|
+
const where = this.where(first.loc);
|
|
344
|
+
this.fail(
|
|
345
|
+
`Duplicate variable ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
|
|
346
|
+
(where ? ` (already declared in ${where})` : ""),
|
|
347
|
+
decl.loc,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
let value = 0;
|
|
351
|
+
if (decl.init) value = this.constValue(decl.init, ctx);
|
|
352
|
+
const info = {
|
|
353
|
+
kind: "var",
|
|
354
|
+
id: this.newVarId(),
|
|
355
|
+
name: decl.name,
|
|
356
|
+
cloud: !!decl.cloud,
|
|
357
|
+
value,
|
|
358
|
+
loc: decl.loc,
|
|
359
|
+
};
|
|
360
|
+
if (global) {
|
|
361
|
+
this.globals.set(decl.name, info);
|
|
362
|
+
} else {
|
|
363
|
+
if (decl.cloud)
|
|
364
|
+
this.fail(
|
|
365
|
+
"`cloud let` is only valid at the top level or inside `stage`",
|
|
366
|
+
decl.loc,
|
|
367
|
+
);
|
|
368
|
+
ctx.vars.set(decl.name, info);
|
|
369
|
+
}
|
|
370
|
+
ctx.target.variables[info.id] = info.cloud
|
|
371
|
+
? [decl.name, value, true]
|
|
372
|
+
: [decl.name, value];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
declareTargetList(ctx, decl) {
|
|
376
|
+
if (ctx.isStage && this.globals.has(decl.name)) {
|
|
377
|
+
const first = this.globals.get(decl.name);
|
|
378
|
+
const where = this.where(first.loc);
|
|
379
|
+
this.fail(
|
|
380
|
+
`Duplicate global variable ${JSON.stringify(decl.name)}` +
|
|
381
|
+
(where ? ` (already declared in ${where})` : ""),
|
|
382
|
+
decl.loc,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (!ctx.isStage && ctx.lists.has(decl.name)) {
|
|
386
|
+
const first = ctx.lists.get(decl.name);
|
|
387
|
+
const where = this.where(first.loc);
|
|
388
|
+
this.fail(
|
|
389
|
+
`Duplicate list ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
|
|
390
|
+
(where ? ` (already declared in ${where})` : ""),
|
|
391
|
+
decl.loc,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const items = decl.items.map((expr) => String(this.constValue(expr, ctx)));
|
|
395
|
+
const info = {
|
|
396
|
+
kind: "list",
|
|
397
|
+
id: this.newVarId(),
|
|
398
|
+
name: decl.name,
|
|
399
|
+
value: items,
|
|
400
|
+
loc: decl.loc,
|
|
401
|
+
};
|
|
402
|
+
if (ctx.isStage) this.globals.set(decl.name, info);
|
|
403
|
+
else ctx.lists.set(decl.name, info);
|
|
404
|
+
ctx.target.lists[info.id] = [decl.name, items];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
declareFunction(ctx, decl) {
|
|
408
|
+
if (ctx.funcs.has(decl.name)) {
|
|
409
|
+
const first = ctx.funcs.get(decl.name);
|
|
410
|
+
const where = this.where(first.loc);
|
|
411
|
+
this.fail(
|
|
412
|
+
`Duplicate function ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
|
|
413
|
+
(where ? ` (already declared in ${where})` : ""),
|
|
414
|
+
decl.loc,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
const argIds = decl.params.map((_, i) => `arg${i}`);
|
|
418
|
+
const proccode = decl.params.length
|
|
419
|
+
? `${decl.name} ${decl.params.map((param) => (param.boolean ? "%b" : "%s")).join(" ")}`
|
|
420
|
+
: decl.name;
|
|
421
|
+
ctx.funcs.set(decl.name, {
|
|
422
|
+
name: decl.name,
|
|
423
|
+
params: decl.params,
|
|
424
|
+
argIds,
|
|
425
|
+
proccode,
|
|
426
|
+
returns: decl.returns,
|
|
427
|
+
warp: !!decl.warp,
|
|
428
|
+
decl,
|
|
429
|
+
loc: decl.loc,
|
|
430
|
+
definitionId: null,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
constValue(expr, ctx) {
|
|
435
|
+
if (!expr) return 0;
|
|
436
|
+
switch (expr.type) {
|
|
437
|
+
case "Number":
|
|
438
|
+
return expr.value;
|
|
439
|
+
case "String":
|
|
440
|
+
return expr.value;
|
|
441
|
+
case "Boolean":
|
|
442
|
+
return expr.value;
|
|
443
|
+
case "Identifier": {
|
|
444
|
+
const resolved = ctx
|
|
445
|
+
? ctx.resolveValue(expr.name)
|
|
446
|
+
: this.globals.get(expr.name);
|
|
447
|
+
if (resolved && resolved.kind === "var") {
|
|
448
|
+
const raw = resolved.value ?? this.globals.get(expr.name)?.value;
|
|
449
|
+
return raw === undefined ? 0 : raw;
|
|
450
|
+
}
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
case "Unary":
|
|
454
|
+
if (expr.op === "-") return -Number(this.constValue(expr.operand, ctx));
|
|
455
|
+
return 0;
|
|
456
|
+
default:
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ----------------------------------------------------------- assets
|
|
462
|
+
|
|
463
|
+
applyCostumes(ctx, property) {
|
|
464
|
+
for (const entry of property.value) {
|
|
465
|
+
const format = detectFormat(entry.value);
|
|
466
|
+
const buffer = this.readAsset(entry.value, entry.loc);
|
|
467
|
+
const { width, height } = imageSize(buffer, format);
|
|
468
|
+
const asset = this.registerAsset(buffer, format);
|
|
469
|
+
ctx.target.costumes.push({
|
|
470
|
+
name: entry.key,
|
|
471
|
+
bitmapResolution: 1,
|
|
472
|
+
dataFormat: format,
|
|
473
|
+
assetId: asset.assetId,
|
|
474
|
+
md5ext: asset.md5ext,
|
|
475
|
+
rotationCenterX: width / 2,
|
|
476
|
+
rotationCenterY: height / 2,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
applySounds(ctx, property) {
|
|
482
|
+
for (const entry of property.value) {
|
|
483
|
+
const format = detectFormat(entry.value);
|
|
484
|
+
const buffer = this.readAsset(entry.value, entry.loc);
|
|
485
|
+
const info =
|
|
486
|
+
format === "wav" ? wavInfo(buffer) : { rate: 48000, sampleCount: 0 };
|
|
487
|
+
const asset = this.registerAsset(buffer, format);
|
|
488
|
+
ctx.target.sounds.push({
|
|
489
|
+
name: entry.key,
|
|
490
|
+
assetId: asset.assetId,
|
|
491
|
+
dataFormat: format,
|
|
492
|
+
format: "",
|
|
493
|
+
rate: info.rate,
|
|
494
|
+
sampleCount: info.sampleCount,
|
|
495
|
+
md5ext: asset.md5ext,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
readAsset(filePath, loc) {
|
|
501
|
+
if (!this.loadAsset)
|
|
502
|
+
this.fail(
|
|
503
|
+
`Cannot load asset ${JSON.stringify(filePath)}: no asset loader configured`,
|
|
504
|
+
loc,
|
|
505
|
+
);
|
|
506
|
+
try {
|
|
507
|
+
return this.loadAsset(filePath, loc);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.fail(
|
|
510
|
+
`Unable to read asset ${JSON.stringify(filePath)}: ${err.message}`,
|
|
511
|
+
loc,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ------------------------------------------------------------- pass 2
|
|
517
|
+
|
|
518
|
+
emitTargetScripts(ctx) {
|
|
519
|
+
const previous = this.ctx;
|
|
520
|
+
this.ctx = ctx;
|
|
521
|
+
const body = ctx.bodyItems;
|
|
522
|
+
for (const item of body) {
|
|
523
|
+
if (item.type === "HatDecl") {
|
|
524
|
+
this.emitHat(ctx, item);
|
|
525
|
+
} else if (item.type === "FunctionDecl") {
|
|
526
|
+
this.emitFunction(ctx, item);
|
|
527
|
+
} else if (
|
|
528
|
+
item.type === "PropertyDecl" ||
|
|
529
|
+
item.type === "VarDecl" ||
|
|
530
|
+
item.type === "ListDecl"
|
|
531
|
+
) {
|
|
532
|
+
// handled in pass 1
|
|
533
|
+
} else {
|
|
534
|
+
this.fail(
|
|
535
|
+
"Statements must appear inside a `when ... { }` hat block",
|
|
536
|
+
item.loc,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
this.ctx = previous;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
emitHat(ctx, decl) {
|
|
544
|
+
const short = decl.event.segments[decl.event.segments.length - 1];
|
|
545
|
+
// Prefer the fully qualified name so extensions can expose hats such as
|
|
546
|
+
// `signals.onMessage`; otherwise fall back to the built-in aliases.
|
|
547
|
+
const fullyQualified = this.catalog[decl.event.name];
|
|
548
|
+
const canonical = HAT_ALIASES[short] || `events.${short}`;
|
|
549
|
+
const entry =
|
|
550
|
+
fullyQualified && fullyQualified.kind === "hat"
|
|
551
|
+
? fullyQualified
|
|
552
|
+
: this.catalog[canonical];
|
|
553
|
+
if (!entry || entry.kind !== "hat")
|
|
554
|
+
this.fail(`Unknown event ${JSON.stringify(short)}`, decl.loc);
|
|
555
|
+
|
|
556
|
+
const pos = ctx.nextScriptPosition();
|
|
557
|
+
const fields = {};
|
|
558
|
+
const inputs = {};
|
|
559
|
+
const argSpecs = entry.args || [];
|
|
560
|
+
if (decl.event.args.length > argSpecs.length) {
|
|
561
|
+
this.fail(
|
|
562
|
+
`Event ${short} accepts at most ${argSpecs.length} argument(s)`,
|
|
563
|
+
decl.loc,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
argSpecs.forEach((spec, i) => {
|
|
567
|
+
const argExpr = decl.event.args[i];
|
|
568
|
+
if (spec.field) {
|
|
569
|
+
if (spec.type === "broadcast") {
|
|
570
|
+
const name = argExpr
|
|
571
|
+
? String(this.literalValue(argExpr))
|
|
572
|
+
: spec.default;
|
|
573
|
+
fields[spec.field] = [name, this.broadcast(name)];
|
|
574
|
+
} else {
|
|
575
|
+
const value = argExpr ? this.literalValue(argExpr) : spec.default;
|
|
576
|
+
fields[spec.field] = [String(value ?? spec.value ?? "")];
|
|
577
|
+
}
|
|
578
|
+
} else if (spec.input) {
|
|
579
|
+
inputs[spec.input] = this.buildInputForSpec(
|
|
580
|
+
{ type: spec.type || "any" },
|
|
581
|
+
argExpr,
|
|
582
|
+
null,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const id = this.addBlock(ctx, {
|
|
588
|
+
opcode: entry.opcode,
|
|
589
|
+
fields,
|
|
590
|
+
inputs,
|
|
591
|
+
topLevel: true,
|
|
592
|
+
x: pos.x,
|
|
593
|
+
y: pos.y,
|
|
594
|
+
});
|
|
595
|
+
// re-parent any inputs that referenced this hat
|
|
596
|
+
this.reparentInputs(ctx, id, inputs);
|
|
597
|
+
const first = this.newStack(ctx, decl.body, id);
|
|
598
|
+
if (first) ctx.target.blocks[id].next = first;
|
|
599
|
+
return id;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
emitFunction(ctx, decl) {
|
|
603
|
+
const fn = ctx.funcs.get(decl.name);
|
|
604
|
+
const pos = ctx.nextScriptPosition();
|
|
605
|
+
const defId = this.addBlock(ctx, {
|
|
606
|
+
opcode: fn.returns
|
|
607
|
+
? "procedures_definition_return"
|
|
608
|
+
: "procedures_definition",
|
|
609
|
+
topLevel: true,
|
|
610
|
+
x: pos.x,
|
|
611
|
+
y: pos.y,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const protoId = uid("proto");
|
|
615
|
+
const protoInputs = {};
|
|
616
|
+
for (let i = 0; i < fn.params.length; i++) {
|
|
617
|
+
const param = fn.params[i];
|
|
618
|
+
const repId = this.addBlock(ctx, {
|
|
619
|
+
opcode: param.boolean
|
|
620
|
+
? "argument_reporter_boolean"
|
|
621
|
+
: "argument_reporter_string_number",
|
|
622
|
+
shadow: true,
|
|
623
|
+
parent: protoId,
|
|
624
|
+
fields: { VALUE: [param.name, null] },
|
|
625
|
+
});
|
|
626
|
+
protoInputs[fn.argIds[i]] = [1, repId];
|
|
627
|
+
}
|
|
628
|
+
ctx.target.blocks[protoId] = {
|
|
629
|
+
opcode: "procedures_prototype",
|
|
630
|
+
next: null,
|
|
631
|
+
parent: defId,
|
|
632
|
+
inputs: protoInputs,
|
|
633
|
+
fields: {},
|
|
634
|
+
shadow: true,
|
|
635
|
+
topLevel: false,
|
|
636
|
+
mutation: {
|
|
637
|
+
tagName: "mutation",
|
|
638
|
+
children: [],
|
|
639
|
+
proccode: fn.proccode,
|
|
640
|
+
argumentids: JSON.stringify(fn.argIds),
|
|
641
|
+
argumentnames: JSON.stringify(fn.params.map((p) => p.name)),
|
|
642
|
+
argumentdefaults: JSON.stringify(fn.params.map(() => "")),
|
|
643
|
+
warp: String(fn.warp),
|
|
644
|
+
...(fn.returns ? { returns: "true" } : {}),
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
ctx.target.blocks[defId].inputs.custom_block = [1, protoId];
|
|
648
|
+
fn.definitionId = defId;
|
|
649
|
+
|
|
650
|
+
// Compile body with parameters in scope.
|
|
651
|
+
const scope = new Map();
|
|
652
|
+
for (const param of fn.params) scope.set(param.name, param);
|
|
653
|
+
ctx.paramScopes.push(scope);
|
|
654
|
+
const first = this.newStack(ctx, decl.body, defId);
|
|
655
|
+
ctx.paramScopes.pop();
|
|
656
|
+
if (first) ctx.target.blocks[defId].next = first;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// --------------------------------------------------------- block utils
|
|
660
|
+
|
|
661
|
+
reparentInputs(ctx, parentId, inputs) {
|
|
662
|
+
for (const value of Object.values(inputs)) {
|
|
663
|
+
if (!Array.isArray(value)) continue;
|
|
664
|
+
for (let i = 1; i < value.length; i++) {
|
|
665
|
+
const ref = value[i];
|
|
666
|
+
if (typeof ref === "string" && ctx.target.blocks[ref]) {
|
|
667
|
+
ctx.target.blocks[ref].parent = parentId;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
addBlock(ctx, opts) {
|
|
674
|
+
const id = uid();
|
|
675
|
+
const block = {
|
|
676
|
+
opcode: opts.opcode,
|
|
677
|
+
next: opts.next || null,
|
|
678
|
+
parent: opts.parent || null,
|
|
679
|
+
inputs: opts.inputs || {},
|
|
680
|
+
fields: opts.fields || {},
|
|
681
|
+
shadow: !!opts.shadow,
|
|
682
|
+
topLevel: !!opts.topLevel,
|
|
683
|
+
};
|
|
684
|
+
if (opts.topLevel) {
|
|
685
|
+
block.x = opts.x || 0;
|
|
686
|
+
block.y = opts.y || 0;
|
|
687
|
+
}
|
|
688
|
+
if (opts.mutation) block.mutation = opts.mutation;
|
|
689
|
+
ctx.target.blocks[id] = block;
|
|
690
|
+
return id;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
newStack(ctx, statements, parentId) {
|
|
694
|
+
let first = null;
|
|
695
|
+
let prev = null;
|
|
696
|
+
for (const statement of statements) {
|
|
697
|
+
if (prev != null && this.blockEndsScript(ctx, prev)) {
|
|
698
|
+
this.fail(
|
|
699
|
+
`No statements can follow ${this.terminalDescription(ctx, prev)}; ` +
|
|
700
|
+
"nothing can connect below a block that ends the script",
|
|
701
|
+
statement.loc,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const id = this.compileStatement(ctx, statement);
|
|
705
|
+
if (id == null) continue;
|
|
706
|
+
if (first == null) {
|
|
707
|
+
first = id;
|
|
708
|
+
ctx.target.blocks[id].parent = parentId;
|
|
709
|
+
} else {
|
|
710
|
+
ctx.target.blocks[prev].next = id;
|
|
711
|
+
ctx.target.blocks[id].parent = prev;
|
|
712
|
+
}
|
|
713
|
+
prev = id;
|
|
714
|
+
}
|
|
715
|
+
return first;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Whether a compiled block has no next-statement connection in the editor.
|
|
720
|
+
* `control.forever` is excluded: PenguinMod grows a next connection for it
|
|
721
|
+
* via its `hasbreak` mutation (see `dynamicNext`).
|
|
722
|
+
* @param {Context} ctx
|
|
723
|
+
* @param {string} id
|
|
724
|
+
*/
|
|
725
|
+
blockEndsScript(ctx, id) {
|
|
726
|
+
const block = ctx.target.blocks[id];
|
|
727
|
+
if (!block) return false;
|
|
728
|
+
if (TERMINAL_OPCODES.has(block.opcode)) return true;
|
|
729
|
+
if (block.opcode === "control_stop") {
|
|
730
|
+
return !(block.mutation && block.mutation.hasnext);
|
|
731
|
+
}
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Human-readable name for a block that ends the script. */
|
|
736
|
+
terminalDescription(ctx, id) {
|
|
737
|
+
const block = ctx.target.blocks[id];
|
|
738
|
+
if (block.opcode === "procedures_return") return "`return`";
|
|
739
|
+
if (block.opcode === "control_delete_this_clone")
|
|
740
|
+
return "`control.deleteThisClone()`";
|
|
741
|
+
if (block.opcode === "control_stop") {
|
|
742
|
+
const option = block.fields.STOP_OPTION
|
|
743
|
+
? block.fields.STOP_OPTION[0]
|
|
744
|
+
: "all";
|
|
745
|
+
return `\`control.stop(${JSON.stringify(option)})\``;
|
|
746
|
+
}
|
|
747
|
+
return `\`${block.opcode}\``;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ------------------------------------------------------- statements
|
|
751
|
+
|
|
752
|
+
compileStatement(ctx, node) {
|
|
753
|
+
switch (node.type) {
|
|
754
|
+
case "BlockCall":
|
|
755
|
+
return this.compileBlockCallStatement(ctx, node);
|
|
756
|
+
case "Assign":
|
|
757
|
+
return this.compileAssign(ctx, node);
|
|
758
|
+
case "If":
|
|
759
|
+
return this.compileIf(ctx, node);
|
|
760
|
+
case "Repeat":
|
|
761
|
+
return this.compileRepeat(ctx, node);
|
|
762
|
+
case "While":
|
|
763
|
+
return this.compileWhile(ctx, node);
|
|
764
|
+
case "Forever":
|
|
765
|
+
return this.compileForever(ctx, node);
|
|
766
|
+
case "Return":
|
|
767
|
+
return this.compileReturn(ctx, node);
|
|
768
|
+
default:
|
|
769
|
+
this.fail(`Unsupported statement ${node.type}`, node.loc);
|
|
770
|
+
}
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
compileBlockCallStatement(ctx, node) {
|
|
775
|
+
const result = this.emitCall(ctx, node, null);
|
|
776
|
+
if (result.kind === "reporter" || result.kind === "boolean") {
|
|
777
|
+
this.fail(
|
|
778
|
+
`${node.name} returns a value and cannot be used as a statement`,
|
|
779
|
+
node.loc,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
if (node.body) {
|
|
783
|
+
const entry = result.entry;
|
|
784
|
+
const subNames = entry
|
|
785
|
+
? Object.keys(entry.inputs || {}).filter(
|
|
786
|
+
(key) => entry.inputs[key].type === "substack",
|
|
787
|
+
)
|
|
788
|
+
: [];
|
|
789
|
+
if (!subNames.length)
|
|
790
|
+
this.fail(`${node.name} cannot take a block body`, node.loc);
|
|
791
|
+
const first = this.newStack(ctx, node.body, result.id);
|
|
792
|
+
if (first) ctx.target.blocks[result.id].inputs[subNames[0]] = [2, first];
|
|
793
|
+
if (node.else) {
|
|
794
|
+
if (subNames[1]) {
|
|
795
|
+
const firstElse = this.newStack(ctx, node.else, result.id);
|
|
796
|
+
if (firstElse)
|
|
797
|
+
ctx.target.blocks[result.id].inputs[subNames[1]] = [2, firstElse];
|
|
798
|
+
} else {
|
|
799
|
+
this.fail(`${node.name} does not support an else branch`, node.loc);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return result.id;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
compileAssign(ctx, node) {
|
|
807
|
+
const resolved = ctx.resolveValue(node.name);
|
|
808
|
+
if (!resolved || resolved.kind !== "var") {
|
|
809
|
+
this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
|
|
810
|
+
}
|
|
811
|
+
const variableField = [resolved.name, resolved.id];
|
|
812
|
+
if (node.op === "=") {
|
|
813
|
+
const id = this.addBlock(ctx, {
|
|
814
|
+
opcode: "data_setvariableto",
|
|
815
|
+
fields: { VARIABLE: variableField },
|
|
816
|
+
});
|
|
817
|
+
ctx.target.blocks[id].inputs.VALUE = this.buildInputForSpec(
|
|
818
|
+
{ type: "any" },
|
|
819
|
+
node.value,
|
|
820
|
+
id,
|
|
821
|
+
);
|
|
822
|
+
return id;
|
|
823
|
+
}
|
|
824
|
+
if (node.op === "+=") {
|
|
825
|
+
const id = this.addBlock(ctx, {
|
|
826
|
+
opcode: "data_changevariableby",
|
|
827
|
+
fields: { VARIABLE: variableField },
|
|
828
|
+
});
|
|
829
|
+
ctx.target.blocks[id].inputs.VALUE = this.buildInputForSpec(
|
|
830
|
+
{ type: "any" },
|
|
831
|
+
node.value,
|
|
832
|
+
id,
|
|
833
|
+
);
|
|
834
|
+
return id;
|
|
835
|
+
}
|
|
836
|
+
// -=, *=, /= become `set x = <op>(x, value)`
|
|
837
|
+
const opMap = {
|
|
838
|
+
"-=": "operators.subtract",
|
|
839
|
+
"*=": "operators.multiply",
|
|
840
|
+
"/=": "operators.divide",
|
|
841
|
+
};
|
|
842
|
+
const id = this.addBlock(ctx, {
|
|
843
|
+
opcode: "data_setvariableto",
|
|
844
|
+
fields: { VARIABLE: variableField },
|
|
845
|
+
});
|
|
846
|
+
const opName = opMap[node.op];
|
|
847
|
+
const operatorId = this.emitBinaryBlock(
|
|
848
|
+
ctx,
|
|
849
|
+
opName,
|
|
850
|
+
id,
|
|
851
|
+
{
|
|
852
|
+
type: "Identifier",
|
|
853
|
+
name: node.name,
|
|
854
|
+
segments: [node.name],
|
|
855
|
+
loc: node.loc,
|
|
856
|
+
},
|
|
857
|
+
node.value,
|
|
858
|
+
);
|
|
859
|
+
ctx.target.blocks[id].inputs.VALUE = [3, operatorId, [10, ""]];
|
|
860
|
+
return id;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
compileIf(ctx, node) {
|
|
864
|
+
const id = this.addBlock(ctx, {
|
|
865
|
+
opcode: node.else ? "control_if_else" : "control_if",
|
|
866
|
+
});
|
|
867
|
+
ctx.target.blocks[id].inputs.CONDITION = this.booleanInput(
|
|
868
|
+
ctx,
|
|
869
|
+
node.cond,
|
|
870
|
+
id,
|
|
871
|
+
);
|
|
872
|
+
const first = this.newStack(ctx, node.then, id);
|
|
873
|
+
if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
|
|
874
|
+
if (node.else) {
|
|
875
|
+
const firstElse = this.newStack(ctx, node.else, id);
|
|
876
|
+
if (firstElse) ctx.target.blocks[id].inputs.SUBSTACK2 = [2, firstElse];
|
|
877
|
+
}
|
|
878
|
+
return id;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
compileRepeat(ctx, node) {
|
|
882
|
+
const id = this.addBlock(ctx, { opcode: "control_repeat" });
|
|
883
|
+
ctx.target.blocks[id].inputs.TIMES = this.buildInputForSpec(
|
|
884
|
+
{ type: "whole" },
|
|
885
|
+
node.count,
|
|
886
|
+
id,
|
|
887
|
+
);
|
|
888
|
+
const first = this.newStack(ctx, node.body, id);
|
|
889
|
+
if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
|
|
890
|
+
return id;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
compileWhile(ctx, node) {
|
|
894
|
+
const id = this.addBlock(ctx, { opcode: "control_repeat_until" });
|
|
895
|
+
// while (cond) == repeat until (not cond)
|
|
896
|
+
const notId = this.addBlock(ctx, { opcode: "operator_not", parent: id });
|
|
897
|
+
ctx.target.blocks[notId].inputs.OPERAND = this.booleanInput(
|
|
898
|
+
ctx,
|
|
899
|
+
node.cond,
|
|
900
|
+
notId,
|
|
901
|
+
);
|
|
902
|
+
ctx.target.blocks[id].inputs.CONDITION = [2, notId];
|
|
903
|
+
const first = this.newStack(ctx, node.body, id);
|
|
904
|
+
if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
|
|
905
|
+
return id;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
compileForever(ctx, node) {
|
|
909
|
+
const id = this.addBlock(ctx, { opcode: "control_forever" });
|
|
910
|
+
const first = this.newStack(ctx, node.body, id);
|
|
911
|
+
if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
|
|
912
|
+
return id;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
compileReturn(ctx, node) {
|
|
916
|
+
const id = this.addBlock(ctx, { opcode: "procedures_return" });
|
|
917
|
+
ctx.target.blocks[id].inputs.return = node.value
|
|
918
|
+
? this.buildInputForSpec({ type: "any" }, node.value, id)
|
|
919
|
+
: [1, [10, ""]];
|
|
920
|
+
return id;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// -------------------------------------------------------- call dispatch
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Emit a call node. Returns {id, kind, entry?}.
|
|
927
|
+
*/
|
|
928
|
+
emitCall(ctx, node, parentId) {
|
|
929
|
+
const segments = node.segments;
|
|
930
|
+
|
|
931
|
+
// List method: `items.push(x)`, `items.at(i)`, ...
|
|
932
|
+
if (segments.length === 2) {
|
|
933
|
+
const listInfo = ctx.resolveList(segments[0]);
|
|
934
|
+
if (listInfo) return this.emitListCall(ctx, node, listInfo, parentId);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const entry = this.catalog[node.name];
|
|
938
|
+
if (entry) return this.emitCatalogCall(ctx, node, entry, parentId);
|
|
939
|
+
|
|
940
|
+
if (segments.length === 1 && ctx.funcs.has(node.name)) {
|
|
941
|
+
return this.emitProcedureCall(
|
|
942
|
+
ctx,
|
|
943
|
+
node,
|
|
944
|
+
ctx.funcs.get(node.name),
|
|
945
|
+
parentId,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
this.fail(
|
|
950
|
+
`Unknown block or function ${JSON.stringify(node.name)}`,
|
|
951
|
+
node.loc,
|
|
952
|
+
);
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
emitCatalogCall(ctx, node, entry, parentId) {
|
|
957
|
+
if (entry.kind === "hat")
|
|
958
|
+
this.fail(`${node.name} is an event and cannot be called`, node.loc);
|
|
959
|
+
const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
|
|
960
|
+
const fieldNames = Object.keys(entry.fields || {});
|
|
961
|
+
const inputNames = Object.keys(entry.inputs || {});
|
|
962
|
+
|
|
963
|
+
if (node.args.length > inputNames.length + fieldNames.length) {
|
|
964
|
+
this.fail(
|
|
965
|
+
`${node.name} expects at most ${inputNames.length + fieldNames.length} argument(s)`,
|
|
966
|
+
node.loc,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const fields = {};
|
|
971
|
+
for (const [name, value] of Object.entries(entry.fields || {})) {
|
|
972
|
+
fields[name] = Array.isArray(value) ? value : [String(value)];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Positional args fill dropdown fields first, then value inputs. This
|
|
976
|
+
// mirrors how mixed blocks read left-to-right in the editor, e.g.
|
|
977
|
+
// `looks.setEffect("ghost", 25)` or `operators.math("sqrt", 9)`.
|
|
978
|
+
const inputs = {};
|
|
979
|
+
let argIndex = 0;
|
|
980
|
+
for (const name of fieldNames) {
|
|
981
|
+
if (node.args[argIndex] !== undefined) {
|
|
982
|
+
fields[name] = [String(this.literalValue(node.args[argIndex]))];
|
|
983
|
+
argIndex += 1;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
for (const name of inputNames) {
|
|
987
|
+
const spec = entry.inputs[name];
|
|
988
|
+
if (spec.type === "substack") continue;
|
|
989
|
+
if (node.args[argIndex] !== undefined) {
|
|
990
|
+
inputs[name] = this.buildInputForSpec(spec, node.args[argIndex], id);
|
|
991
|
+
argIndex += 1;
|
|
992
|
+
} else {
|
|
993
|
+
inputs[name] = this.defaultInput(spec);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
ctx.target.blocks[id].inputs = inputs;
|
|
998
|
+
ctx.target.blocks[id].fields = fields;
|
|
999
|
+
this.reparentInputs(ctx, id, inputs);
|
|
1000
|
+
|
|
1001
|
+
if (entry.stopBlock) {
|
|
1002
|
+
const stopOption = fields.STOP_OPTION ? fields.STOP_OPTION[0] : "all";
|
|
1003
|
+
const hasNext =
|
|
1004
|
+
stopOption === "other scripts in sprite" ||
|
|
1005
|
+
stopOption === "other scripts in stage";
|
|
1006
|
+
ctx.target.blocks[id].mutation = {
|
|
1007
|
+
tagName: "mutation",
|
|
1008
|
+
hasnext: hasNext,
|
|
1009
|
+
children: [],
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return { id, kind: entry.kind, entry };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
emitListCall(ctx, node, listInfo, parentId) {
|
|
1017
|
+
const method = node.segments[1];
|
|
1018
|
+
let spec = null;
|
|
1019
|
+
let canonical = method;
|
|
1020
|
+
for (const [name, entry] of Object.entries(LIST_OPS)) {
|
|
1021
|
+
if (name === method || (entry.aliases || []).includes(method)) {
|
|
1022
|
+
spec = entry;
|
|
1023
|
+
canonical = name;
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (!spec)
|
|
1028
|
+
this.fail(
|
|
1029
|
+
`Unknown list operation ${JSON.stringify(method)} on list ${JSON.stringify(listInfo.name)}`,
|
|
1030
|
+
node.loc,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
const fields = { LIST: [listInfo.name, listInfo.id] };
|
|
1034
|
+
const id = this.addBlock(ctx, {
|
|
1035
|
+
opcode: spec.opcode,
|
|
1036
|
+
fields,
|
|
1037
|
+
parent: parentId,
|
|
1038
|
+
});
|
|
1039
|
+
spec.inputs.forEach((inputName, i) => {
|
|
1040
|
+
if (node.args[i] === undefined) {
|
|
1041
|
+
ctx.target.blocks[id].inputs[inputName] = [1, [4, "0"]];
|
|
1042
|
+
} else {
|
|
1043
|
+
const specType =
|
|
1044
|
+
inputName === "INDEX" ? { type: "whole" } : { type: "any" };
|
|
1045
|
+
ctx.target.blocks[id].inputs[inputName] = this.buildInputForSpec(
|
|
1046
|
+
specType,
|
|
1047
|
+
node.args[i],
|
|
1048
|
+
id,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
this.reparentInputs(ctx, id, ctx.target.blocks[id].inputs);
|
|
1053
|
+
void canonical;
|
|
1054
|
+
return { id, kind: spec.kind, entry: spec };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
emitProcedureCall(ctx, node, fn, parentId) {
|
|
1058
|
+
const id = this.addBlock(ctx, {
|
|
1059
|
+
opcode: "procedures_call",
|
|
1060
|
+
parent: parentId,
|
|
1061
|
+
});
|
|
1062
|
+
const inputs = {};
|
|
1063
|
+
fn.argIds.forEach((argId, i) => {
|
|
1064
|
+
const param = fn.params[i];
|
|
1065
|
+
const spec = param.boolean ? { type: "boolean" } : { type: "any" };
|
|
1066
|
+
inputs[argId] =
|
|
1067
|
+
node.args[i] !== undefined
|
|
1068
|
+
? this.buildInputForSpec(spec, node.args[i], id)
|
|
1069
|
+
: this.defaultInput(spec);
|
|
1070
|
+
});
|
|
1071
|
+
const mutation = {
|
|
1072
|
+
tagName: "mutation",
|
|
1073
|
+
children: [],
|
|
1074
|
+
proccode: fn.proccode,
|
|
1075
|
+
argumentids: JSON.stringify(fn.argIds),
|
|
1076
|
+
warp: String(fn.warp),
|
|
1077
|
+
};
|
|
1078
|
+
if (fn.returns) mutation.returns = "true";
|
|
1079
|
+
ctx.target.blocks[id].mutation = mutation;
|
|
1080
|
+
ctx.target.blocks[id].inputs = inputs;
|
|
1081
|
+
this.reparentInputs(ctx, id, inputs);
|
|
1082
|
+
return { id, kind: fn.returns ? "reporter" : "command", entry: {} };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ------------------------------------------------------- input builders
|
|
1086
|
+
|
|
1087
|
+
buildInputForSpec(spec, expr, parentId) {
|
|
1088
|
+
if (expr === undefined || expr === null) return this.defaultInput(spec);
|
|
1089
|
+
if (spec.type === "menu") return this.menuInput(spec, expr, parentId);
|
|
1090
|
+
if (spec.type === "broadcast") return this.broadcastInput(expr, parentId);
|
|
1091
|
+
if (spec.type === "boolean")
|
|
1092
|
+
return this.booleanInput(this.ctx, expr, parentId);
|
|
1093
|
+
const desc = this.emitExpr(this.ctx, expr);
|
|
1094
|
+
return this.valueInput(desc, spec, parentId);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
defaultInput(spec) {
|
|
1098
|
+
if (spec.type === "boolean")
|
|
1099
|
+
return [2, this.booleanLiteralBlock(this.ctx, true)];
|
|
1100
|
+
if (spec.type === "menu") {
|
|
1101
|
+
const value = spec.default ?? "";
|
|
1102
|
+
const id = this.addBlock(this.ctx, {
|
|
1103
|
+
opcode: spec.menu,
|
|
1104
|
+
shadow: true,
|
|
1105
|
+
fields: { [spec.field]: [String(value)] },
|
|
1106
|
+
});
|
|
1107
|
+
return [1, id];
|
|
1108
|
+
}
|
|
1109
|
+
return [1, this.defaultPrim(spec)];
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
defaultPrim(spec) {
|
|
1113
|
+
switch (spec.type) {
|
|
1114
|
+
case "number":
|
|
1115
|
+
return [4, "0"];
|
|
1116
|
+
case "positive":
|
|
1117
|
+
return [5, "0"];
|
|
1118
|
+
case "whole":
|
|
1119
|
+
return [6, "0"];
|
|
1120
|
+
case "integer":
|
|
1121
|
+
return [7, "0"];
|
|
1122
|
+
case "angle":
|
|
1123
|
+
return [8, "90"];
|
|
1124
|
+
case "color":
|
|
1125
|
+
return [9, "#000000"];
|
|
1126
|
+
case "string":
|
|
1127
|
+
return [10, ""];
|
|
1128
|
+
default:
|
|
1129
|
+
return [10, ""];
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
menuInput(spec, expr, parentId) {
|
|
1134
|
+
const value = this.literalValue(expr);
|
|
1135
|
+
const id = this.addBlock(this.ctx, {
|
|
1136
|
+
opcode: spec.menu,
|
|
1137
|
+
shadow: true,
|
|
1138
|
+
parent: parentId,
|
|
1139
|
+
fields: { [spec.field]: [String(value ?? spec.default ?? "")] },
|
|
1140
|
+
});
|
|
1141
|
+
return [1, id];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
broadcastInput(expr, parentId) {
|
|
1145
|
+
const name = String(this.literalValue(expr));
|
|
1146
|
+
const id = this.broadcast(name);
|
|
1147
|
+
void parentId;
|
|
1148
|
+
return [1, [11, name, id]];
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
booleanInput(ctx, expr, parentId) {
|
|
1152
|
+
const desc = this.emitExpr(ctx, expr);
|
|
1153
|
+
if (desc.k === "block") {
|
|
1154
|
+
ctx.target.blocks[desc.id].parent = parentId;
|
|
1155
|
+
return [2, desc.id];
|
|
1156
|
+
}
|
|
1157
|
+
// Literal/primitive in boolean position: compare against "true".
|
|
1158
|
+
const id = this.addBlock(ctx, {
|
|
1159
|
+
opcode: "operator_equals",
|
|
1160
|
+
parent: parentId,
|
|
1161
|
+
});
|
|
1162
|
+
ctx.target.blocks[id].inputs.OPERAND1 = [
|
|
1163
|
+
1,
|
|
1164
|
+
desc.k === "prim"
|
|
1165
|
+
? desc.prim
|
|
1166
|
+
: this.primForDesc(desc, { type: "string" }),
|
|
1167
|
+
];
|
|
1168
|
+
ctx.target.blocks[id].inputs.OPERAND2 = [1, [10, "true"]];
|
|
1169
|
+
return [2, id];
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
booleanLiteralBlock(ctx, value) {
|
|
1173
|
+
const id = this.addBlock(ctx, { opcode: "operator_equals" });
|
|
1174
|
+
ctx.target.blocks[id].inputs.OPERAND1 = [1, [10, value ? "true" : "false"]];
|
|
1175
|
+
ctx.target.blocks[id].inputs.OPERAND2 = [1, [10, "true"]];
|
|
1176
|
+
return id;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
primForDesc(desc, spec) {
|
|
1180
|
+
if (desc.k === "var") return [12, desc.name, desc.id];
|
|
1181
|
+
if (desc.k === "list") return [13, desc.name, desc.id];
|
|
1182
|
+
return this.defaultPrim(spec);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
valueInput(desc, spec, parentId) {
|
|
1186
|
+
if (desc.k === "var") {
|
|
1187
|
+
return [3, [12, desc.name, desc.id], this.defaultPrim(spec)];
|
|
1188
|
+
}
|
|
1189
|
+
if (desc.k === "list") {
|
|
1190
|
+
return [3, [13, desc.name, desc.id], this.defaultPrim(spec)];
|
|
1191
|
+
}
|
|
1192
|
+
if (desc.k === "prim") {
|
|
1193
|
+
if (spec.type === "color") return [1, [9, normalizeColor(desc.prim[1])]];
|
|
1194
|
+
if (spec.type === "any") return [1, desc.prim];
|
|
1195
|
+
return [1, this.coercePrim(desc, spec)];
|
|
1196
|
+
}
|
|
1197
|
+
// block
|
|
1198
|
+
if (this.ctx.target.blocks[desc.id])
|
|
1199
|
+
this.ctx.target.blocks[desc.id].parent = parentId;
|
|
1200
|
+
return [3, desc.id, this.defaultPrim(spec)];
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
coercePrim(desc, spec) {
|
|
1204
|
+
const raw = desc.prim[1];
|
|
1205
|
+
if (spec.type === "string") return [10, String(raw)];
|
|
1206
|
+
const num = Number(raw);
|
|
1207
|
+
const value = Number.isFinite(num) ? String(num) : "0";
|
|
1208
|
+
const typeNum = NUMBER_TYPES[spec.type] || 4;
|
|
1209
|
+
return [typeNum, value];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
literalValue(expr) {
|
|
1213
|
+
if (!expr) return "";
|
|
1214
|
+
switch (expr.type) {
|
|
1215
|
+
case "String":
|
|
1216
|
+
return expr.value;
|
|
1217
|
+
case "Number":
|
|
1218
|
+
return expr.value;
|
|
1219
|
+
case "Boolean":
|
|
1220
|
+
return expr.value;
|
|
1221
|
+
case "Identifier":
|
|
1222
|
+
return expr.name;
|
|
1223
|
+
default:
|
|
1224
|
+
return "";
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// -------------------------------------------------------- expressions
|
|
1229
|
+
|
|
1230
|
+
emitExpr(ctx, node) {
|
|
1231
|
+
if (!node) return { k: "prim", prim: [10, ""] };
|
|
1232
|
+
switch (node.type) {
|
|
1233
|
+
case "Number":
|
|
1234
|
+
return { k: "prim", prim: [4, String(node.value)] };
|
|
1235
|
+
case "String":
|
|
1236
|
+
return { k: "prim", prim: [10, node.value] };
|
|
1237
|
+
case "Boolean":
|
|
1238
|
+
return { k: "prim", prim: [10, node.value ? "true" : "false"] };
|
|
1239
|
+
case "Identifier":
|
|
1240
|
+
return this.emitIdentifier(ctx, node);
|
|
1241
|
+
case "Call": {
|
|
1242
|
+
const result = this.emitCall(ctx, node, null);
|
|
1243
|
+
return { k: "block", id: result.id, kind: result.kind };
|
|
1244
|
+
}
|
|
1245
|
+
case "Binary":
|
|
1246
|
+
return this.emitBinary(ctx, node);
|
|
1247
|
+
case "Unary":
|
|
1248
|
+
return this.emitUnary(ctx, node);
|
|
1249
|
+
default:
|
|
1250
|
+
this.fail(`Unsupported expression ${node.type}`, node.loc);
|
|
1251
|
+
}
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
emitIdentifier(ctx, node) {
|
|
1256
|
+
if (node.segments.length > 1)
|
|
1257
|
+
this.fail(
|
|
1258
|
+
`Unexpected dotted name ${JSON.stringify(node.name)}`,
|
|
1259
|
+
node.loc,
|
|
1260
|
+
);
|
|
1261
|
+
const resolved = ctx.resolveValue(node.name);
|
|
1262
|
+
if (!resolved)
|
|
1263
|
+
this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
|
|
1264
|
+
if (resolved.kind === "param") {
|
|
1265
|
+
const id = this.addBlock(ctx, {
|
|
1266
|
+
opcode: resolved.param.boolean
|
|
1267
|
+
? "argument_reporter_boolean"
|
|
1268
|
+
: "argument_reporter_string_number",
|
|
1269
|
+
fields: { VALUE: [resolved.param.name, null] },
|
|
1270
|
+
});
|
|
1271
|
+
return {
|
|
1272
|
+
k: "block",
|
|
1273
|
+
id,
|
|
1274
|
+
kind: resolved.param.boolean ? "boolean" : "reporter",
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
if (resolved.kind === "var")
|
|
1278
|
+
return { k: "var", name: resolved.name, id: resolved.id };
|
|
1279
|
+
return { k: "list", name: resolved.name, id: resolved.id };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
emitBinary(ctx, node) {
|
|
1283
|
+
switch (node.op) {
|
|
1284
|
+
case "and":
|
|
1285
|
+
return {
|
|
1286
|
+
k: "block",
|
|
1287
|
+
id: this.emitBinaryBlock(
|
|
1288
|
+
ctx,
|
|
1289
|
+
"operators.and",
|
|
1290
|
+
null,
|
|
1291
|
+
node.left,
|
|
1292
|
+
node.right,
|
|
1293
|
+
),
|
|
1294
|
+
kind: "boolean",
|
|
1295
|
+
};
|
|
1296
|
+
case "or":
|
|
1297
|
+
return {
|
|
1298
|
+
k: "block",
|
|
1299
|
+
id: this.emitBinaryBlock(
|
|
1300
|
+
ctx,
|
|
1301
|
+
"operators.or",
|
|
1302
|
+
null,
|
|
1303
|
+
node.left,
|
|
1304
|
+
node.right,
|
|
1305
|
+
),
|
|
1306
|
+
kind: "boolean",
|
|
1307
|
+
};
|
|
1308
|
+
case "==":
|
|
1309
|
+
return {
|
|
1310
|
+
k: "block",
|
|
1311
|
+
id: this.emitBinaryBlock(
|
|
1312
|
+
ctx,
|
|
1313
|
+
"operators.equals",
|
|
1314
|
+
null,
|
|
1315
|
+
node.left,
|
|
1316
|
+
node.right,
|
|
1317
|
+
),
|
|
1318
|
+
kind: "boolean",
|
|
1319
|
+
};
|
|
1320
|
+
case "<":
|
|
1321
|
+
return {
|
|
1322
|
+
k: "block",
|
|
1323
|
+
id: this.emitBinaryBlock(
|
|
1324
|
+
ctx,
|
|
1325
|
+
"operators.lt",
|
|
1326
|
+
null,
|
|
1327
|
+
node.left,
|
|
1328
|
+
node.right,
|
|
1329
|
+
),
|
|
1330
|
+
kind: "boolean",
|
|
1331
|
+
};
|
|
1332
|
+
case ">":
|
|
1333
|
+
return {
|
|
1334
|
+
k: "block",
|
|
1335
|
+
id: this.emitBinaryBlock(
|
|
1336
|
+
ctx,
|
|
1337
|
+
"operators.gt",
|
|
1338
|
+
null,
|
|
1339
|
+
node.left,
|
|
1340
|
+
node.right,
|
|
1341
|
+
),
|
|
1342
|
+
kind: "boolean",
|
|
1343
|
+
};
|
|
1344
|
+
case "!=":
|
|
1345
|
+
return this.emitNot(ctx, {
|
|
1346
|
+
k: "block",
|
|
1347
|
+
id: this.emitBinaryBlock(
|
|
1348
|
+
ctx,
|
|
1349
|
+
"operators.equals",
|
|
1350
|
+
null,
|
|
1351
|
+
node.left,
|
|
1352
|
+
node.right,
|
|
1353
|
+
),
|
|
1354
|
+
});
|
|
1355
|
+
case "<=":
|
|
1356
|
+
return this.emitNot(ctx, {
|
|
1357
|
+
k: "block",
|
|
1358
|
+
id: this.emitBinaryBlock(
|
|
1359
|
+
ctx,
|
|
1360
|
+
"operators.gt",
|
|
1361
|
+
null,
|
|
1362
|
+
node.left,
|
|
1363
|
+
node.right,
|
|
1364
|
+
),
|
|
1365
|
+
});
|
|
1366
|
+
case ">=":
|
|
1367
|
+
return this.emitNot(ctx, {
|
|
1368
|
+
k: "block",
|
|
1369
|
+
id: this.emitBinaryBlock(
|
|
1370
|
+
ctx,
|
|
1371
|
+
"operators.lt",
|
|
1372
|
+
null,
|
|
1373
|
+
node.left,
|
|
1374
|
+
node.right,
|
|
1375
|
+
),
|
|
1376
|
+
});
|
|
1377
|
+
case "+":
|
|
1378
|
+
return {
|
|
1379
|
+
k: "block",
|
|
1380
|
+
id: this.emitBinaryBlock(
|
|
1381
|
+
ctx,
|
|
1382
|
+
"operators.add",
|
|
1383
|
+
null,
|
|
1384
|
+
node.left,
|
|
1385
|
+
node.right,
|
|
1386
|
+
),
|
|
1387
|
+
kind: "reporter",
|
|
1388
|
+
};
|
|
1389
|
+
case "-":
|
|
1390
|
+
return {
|
|
1391
|
+
k: "block",
|
|
1392
|
+
id: this.emitBinaryBlock(
|
|
1393
|
+
ctx,
|
|
1394
|
+
"operators.subtract",
|
|
1395
|
+
null,
|
|
1396
|
+
node.left,
|
|
1397
|
+
node.right,
|
|
1398
|
+
),
|
|
1399
|
+
kind: "reporter",
|
|
1400
|
+
};
|
|
1401
|
+
case "*":
|
|
1402
|
+
return {
|
|
1403
|
+
k: "block",
|
|
1404
|
+
id: this.emitBinaryBlock(
|
|
1405
|
+
ctx,
|
|
1406
|
+
"operators.multiply",
|
|
1407
|
+
null,
|
|
1408
|
+
node.left,
|
|
1409
|
+
node.right,
|
|
1410
|
+
),
|
|
1411
|
+
kind: "reporter",
|
|
1412
|
+
};
|
|
1413
|
+
case "/":
|
|
1414
|
+
return {
|
|
1415
|
+
k: "block",
|
|
1416
|
+
id: this.emitBinaryBlock(
|
|
1417
|
+
ctx,
|
|
1418
|
+
"operators.divide",
|
|
1419
|
+
null,
|
|
1420
|
+
node.left,
|
|
1421
|
+
node.right,
|
|
1422
|
+
),
|
|
1423
|
+
kind: "reporter",
|
|
1424
|
+
};
|
|
1425
|
+
case "%":
|
|
1426
|
+
return {
|
|
1427
|
+
k: "block",
|
|
1428
|
+
id: this.emitBinaryBlock(
|
|
1429
|
+
ctx,
|
|
1430
|
+
"operators.mod",
|
|
1431
|
+
null,
|
|
1432
|
+
node.left,
|
|
1433
|
+
node.right,
|
|
1434
|
+
),
|
|
1435
|
+
kind: "reporter",
|
|
1436
|
+
};
|
|
1437
|
+
default:
|
|
1438
|
+
this.fail(`Unsupported operator ${JSON.stringify(node.op)}`, node.loc);
|
|
1439
|
+
}
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Emit a catalog binary/boolean block, returning its id.
|
|
1445
|
+
* @param {string} catalogName
|
|
1446
|
+
* @param {string|null} parentId
|
|
1447
|
+
* @param {object} left
|
|
1448
|
+
* @param {object} right
|
|
1449
|
+
*/
|
|
1450
|
+
emitBinaryBlock(ctx, catalogName, parentId, left, right) {
|
|
1451
|
+
const entry = this.catalog[catalogName];
|
|
1452
|
+
if (!entry)
|
|
1453
|
+
this.fail(`Internal error: missing catalog entry ${catalogName}`);
|
|
1454
|
+
const inputNames = Object.keys(entry.inputs || {});
|
|
1455
|
+
const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
|
|
1456
|
+
const exprs = [left, right];
|
|
1457
|
+
inputNames.forEach((name, i) => {
|
|
1458
|
+
const spec = entry.inputs[name];
|
|
1459
|
+
ctx.target.blocks[id].inputs[name] = this.buildInputForSpec(
|
|
1460
|
+
spec,
|
|
1461
|
+
exprs[i],
|
|
1462
|
+
id,
|
|
1463
|
+
);
|
|
1464
|
+
});
|
|
1465
|
+
return id;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
emitNot(ctx, desc) {
|
|
1469
|
+
const id = this.addBlock(ctx, { opcode: "operator_not" });
|
|
1470
|
+
ctx.target.blocks[id].inputs.OPERAND = [2, desc.id];
|
|
1471
|
+
ctx.target.blocks[desc.id].parent = id;
|
|
1472
|
+
return { k: "block", id, kind: "boolean" };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
emitUnary(ctx, node) {
|
|
1476
|
+
if (node.op === "not") {
|
|
1477
|
+
const operand = this.emitExpr(ctx, node.operand);
|
|
1478
|
+
if (operand.k === "block") {
|
|
1479
|
+
const id = this.addBlock(ctx, { opcode: "operator_not" });
|
|
1480
|
+
ctx.target.blocks[id].inputs.OPERAND = [2, operand.id];
|
|
1481
|
+
ctx.target.blocks[operand.id].parent = id;
|
|
1482
|
+
return { k: "block", id, kind: "boolean" };
|
|
1483
|
+
}
|
|
1484
|
+
const id = this.addBlock(ctx, { opcode: "operator_not" });
|
|
1485
|
+
ctx.target.blocks[id].inputs.OPERAND = this.booleanInput(
|
|
1486
|
+
ctx,
|
|
1487
|
+
node.operand,
|
|
1488
|
+
id,
|
|
1489
|
+
);
|
|
1490
|
+
return { k: "block", id, kind: "boolean" };
|
|
1491
|
+
}
|
|
1492
|
+
if (node.op === "-") {
|
|
1493
|
+
const id = this.addBlock(ctx, { opcode: "operator_subtract" });
|
|
1494
|
+
ctx.target.blocks[id].inputs.NUM1 = [1, [4, "0"]];
|
|
1495
|
+
ctx.target.blocks[id].inputs.NUM2 = this.buildInputForSpec(
|
|
1496
|
+
{ type: "number" },
|
|
1497
|
+
node.operand,
|
|
1498
|
+
id,
|
|
1499
|
+
);
|
|
1500
|
+
return { k: "block", id, kind: "reporter" };
|
|
1501
|
+
}
|
|
1502
|
+
this.fail(
|
|
1503
|
+
`Unsupported unary operator ${JSON.stringify(node.op)}`,
|
|
1504
|
+
node.loc,
|
|
1505
|
+
);
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// ------------------------------------------------------------ assembly
|
|
1510
|
+
|
|
1511
|
+
assembleProject(contexts) {
|
|
1512
|
+
const targets = [];
|
|
1513
|
+
const extensions = new Set();
|
|
1514
|
+
|
|
1515
|
+
for (const ctx of contexts) {
|
|
1516
|
+
const target = ctx.target;
|
|
1517
|
+
if (target.isStage) {
|
|
1518
|
+
// broadcasts live on the stage
|
|
1519
|
+
for (const [name, id] of this.broadcasts) target.broadcasts[id] = name;
|
|
1520
|
+
this.ensureStageAssets(target);
|
|
1521
|
+
const comment = configComment(this.projectConfig);
|
|
1522
|
+
if (comment) {
|
|
1523
|
+
const commentId = uid("c");
|
|
1524
|
+
target.comments[commentId] = {
|
|
1525
|
+
blockId: null,
|
|
1526
|
+
x: 50,
|
|
1527
|
+
y: 50,
|
|
1528
|
+
width: 350,
|
|
1529
|
+
height: 170,
|
|
1530
|
+
minimized: false,
|
|
1531
|
+
text: comment,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
} else {
|
|
1535
|
+
this.ensureSpriteAssets(target);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
for (const block of Object.values(target.blocks)) {
|
|
1539
|
+
const ext =
|
|
1540
|
+
this.opcodeExtensions.get(block.opcode) ||
|
|
1541
|
+
extensionForOpcode(block.opcode);
|
|
1542
|
+
if (ext) extensions.add(ext);
|
|
1543
|
+
}
|
|
1544
|
+
targets.push(this.serializeTarget(target));
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Sprites with an explicit layer keep it; others are ordered by declaration.
|
|
1548
|
+
targets.forEach((target, index) => {
|
|
1549
|
+
if (
|
|
1550
|
+
!target.isStage &&
|
|
1551
|
+
(target.layerOrder === 0 || target.layerOrder == null)
|
|
1552
|
+
) {
|
|
1553
|
+
target.layerOrder = index;
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
const project = {
|
|
1558
|
+
targets,
|
|
1559
|
+
monitors: [],
|
|
1560
|
+
extensions: Array.from(extensions).sort(),
|
|
1561
|
+
meta: { semver: "3.0.0", vm: "0.2.0", agent: "" },
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
const urls = {};
|
|
1565
|
+
for (const id of Object.keys(this.extensionUrls)) {
|
|
1566
|
+
if (extensions.has(id)) urls[id] = this.extensionUrls[id];
|
|
1567
|
+
}
|
|
1568
|
+
// Include configured extensions even if no block references them yet.
|
|
1569
|
+
for (const id of Object.keys(this.extensionUrls)) {
|
|
1570
|
+
if (!project.extensions.includes(id)) project.extensions.push(id);
|
|
1571
|
+
urls[id] = this.extensionUrls[id];
|
|
1572
|
+
}
|
|
1573
|
+
project.extensions.sort();
|
|
1574
|
+
if (Object.keys(urls).length) project.extensionURLs = urls;
|
|
1575
|
+
|
|
1576
|
+
return project;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
ensureStageAssets(target) {
|
|
1580
|
+
if (target.costumes.length === 0) {
|
|
1581
|
+
const width = this.projectConfig.width || 480;
|
|
1582
|
+
const height = this.projectConfig.height || 360;
|
|
1583
|
+
const buffer = Buffer.from(blankSvg(width, height, true));
|
|
1584
|
+
const asset = this.registerAsset(buffer, "svg");
|
|
1585
|
+
target.costumes.push({
|
|
1586
|
+
name: "backdrop1",
|
|
1587
|
+
bitmapResolution: 1,
|
|
1588
|
+
dataFormat: "svg",
|
|
1589
|
+
assetId: asset.assetId,
|
|
1590
|
+
md5ext: asset.md5ext,
|
|
1591
|
+
rotationCenterX: width / 2,
|
|
1592
|
+
rotationCenterY: height / 2,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
ensureSpriteAssets(target) {
|
|
1598
|
+
if (target.costumes.length === 0) {
|
|
1599
|
+
const buffer = Buffer.from(blankSvg(100, 100, false));
|
|
1600
|
+
const asset = this.registerAsset(buffer, "svg");
|
|
1601
|
+
target.costumes.push({
|
|
1602
|
+
name: "costume1",
|
|
1603
|
+
bitmapResolution: 1,
|
|
1604
|
+
dataFormat: "svg",
|
|
1605
|
+
assetId: asset.assetId,
|
|
1606
|
+
md5ext: asset.md5ext,
|
|
1607
|
+
rotationCenterX: 50,
|
|
1608
|
+
rotationCenterY: 50,
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
serializeTarget(target) {
|
|
1614
|
+
const base = {
|
|
1615
|
+
isStage: target.isStage,
|
|
1616
|
+
name: target.isStage ? "Stage" : target.name,
|
|
1617
|
+
variables: target.variables,
|
|
1618
|
+
lists: target.lists,
|
|
1619
|
+
broadcasts: target.broadcasts,
|
|
1620
|
+
blocks: target.blocks,
|
|
1621
|
+
comments: target.comments,
|
|
1622
|
+
currentCostume: 0,
|
|
1623
|
+
costumes: target.costumes,
|
|
1624
|
+
sounds: target.sounds,
|
|
1625
|
+
volume: target.volume,
|
|
1626
|
+
layerOrder: target.layerOrder,
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
if (target.isStage) {
|
|
1630
|
+
return {
|
|
1631
|
+
...base,
|
|
1632
|
+
tempo: 60,
|
|
1633
|
+
videoTransparency: 50,
|
|
1634
|
+
videoState: "on",
|
|
1635
|
+
textToSpeechLanguage: null,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
...base,
|
|
1640
|
+
visible: target.visible,
|
|
1641
|
+
x: target.x,
|
|
1642
|
+
y: target.y,
|
|
1643
|
+
size: target.size,
|
|
1644
|
+
direction: target.direction,
|
|
1645
|
+
draggable: target.draggable,
|
|
1646
|
+
rotationStyle: target.rotationStyle,
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Convenience helper: compile an AST (or source string) into project JSON.
|
|
1653
|
+
* @param {object} ast
|
|
1654
|
+
* @param {object} options
|
|
1655
|
+
*/
|
|
1656
|
+
export function compile(ast, options) {
|
|
1657
|
+
return new Compiler(options).compile(ast);
|
|
1658
|
+
}
|