UNPKG

@webwriter/block-based-code

Version:

Write block-based code (e.g. Scratch) and run it.

231 lines (204 loc) 7.54 kB
/** * The VirtualMachine class is an abstract class that provides a simple interface to run code in a worker. */ export abstract class VirtualMachine { /** * Map of event types to their worker instances. * @private */ private workers: Map<string, Worker> = new Map(); /** * Map of event types to their complete resolve functions. * @private */ private completeResolveFunctions: Map<string, () => void> = new Map(); /** * Shared variable state across all workers. * @private */ private variables: Map<string, number> = new Map(); /** * The highlight callback function. This function is called when the worker wants to highlight a block. * @private */ private highlightCallback: ((id: string) => void) | undefined; /** * The execution state callback function. This function is called when the execution state changes. * @private */ private executionStateCallback: ((runningEventTypes: Set<string>) => void) | undefined; /** * Starts the worker with the given code and delay. * @param code The code to run in the worker. * @param delay The delay between each step in milliseconds. * @param eventType The event type to trigger (e.g., "whenStartClicked", "whenSpriteClicked"). */ public async start(code: string, delay: number, eventType: string = "whenStartClicked"): Promise<void> { // Stop any existing worker for this event type this.stopEvent(eventType); this.initWorker(code, delay, eventType); // Notify about execution state change if (this.executionStateCallback) { this.executionStateCallback(new Set(this.workers.keys())); } await new Promise<void>((resolve) => { this.completeResolveFunctions.set(eventType, resolve); }); } /** * Stops a specific event worker. * @param eventType The event type to stop. */ public stopEvent(eventType: string): void { const worker = this.workers.get(eventType); if (!worker) return; this.highlight(null); const resolveFunction = this.completeResolveFunctions.get(eventType); if (resolveFunction) { resolveFunction(); } this.completeResolveFunctions.delete(eventType); worker.terminate(); this.workers.delete(eventType); // Notify about execution state change if (this.executionStateCallback) { this.executionStateCallback(new Set(this.workers.keys())); } } /** * Stops all workers. */ public stop(): void { Array.from(this.workers.keys()).forEach((eventType) => this.stopEvent(eventType)); } /** * Resets the stage to its initial state. */ public abstract reset(): void; /** * Resets all variables to their initial state. */ public resetVariables(): void { this.variables.clear(); } /** * Sets the highlight callback function. * @param callback The highlight callback function. */ public setHighlightCallback(callback: (id: string) => void): void { this.highlightCallback = callback; } /** * Sets the execution state callback function. * @param callback The execution state callback function. Receives a Set of currently running event types. */ public setExecutionStateCallback(callback: (runningEventTypes: Set<string>) => void): void { this.executionStateCallback = callback; } /** * The callables that can be called from the worker. This method should be overridden in the child class. * @protected */ protected abstract get callables(): ((...args: any[]) => void)[]; /** * Initializes the worker with the given code and delay. * @param code The code to run in the worker. * @param delay The delay between each step in milliseconds. * @param eventType The event type to trigger. * @private */ private initWorker(code: string, delay: number, eventType: string): void { const script = this.generateWorkerScript(code, delay, eventType); const url = this.generateWorkerScriptUrl(script); const worker = new Worker(url); this.workers.set(eventType, worker); worker.onmessage = async (event: MessageEvent<{ type: string, args: any[] }>) => { if (event.data.type === "complete") { this.stopEvent(eventType); return; } const result = this[event.data.type](...event.data.args); if (result != null) { if (result instanceof Promise) { const resolvedValue = await result; worker.postMessage({ type: "result", args: [resolvedValue] }); } else { worker.postMessage({ type: "result", args: [result] }); } } }; } /** * Generates the worker script with the given code and delay. * @param code The code to run in the worker. * @param delay The delay between each step in milliseconds. * @param eventType The event type to trigger. * @private */ private generateWorkerScript(code: string, delay: number, eventType: string): string { let script = ""; script += "let resultResolveFunction;\n"; script += "async function wait(s) { await new Promise((resolve) => { setTimeout(resolve, s * 1e3) }); }\n"; script += `async function delay() { await new Promise((resolve) => { setTimeout(resolve, ${delay === 0 ? 16.6 : delay}) }); }\n`; script += "function random(from, to) { return Math.floor(Math.random() * (to - from + 1)) + from; }\n"; script += "onmessage = function (event) { if (event.data.type === 'result') { resultResolveFunction(event.data.args[0]); } };\n"; [ this.highlight, this.stop, this.getVariable, this.setVariable, ...this.callables, ].forEach((callable) => { const args = Array(callable.length).fill("x").map((x, i) => `${x}${i}`).join(", "); const message = `{ type: "${callable.name}", args: [${args}] }`; if (callable.name.startsWith("get") || callable.name.includes("UntilDone")) { script += `async function ${callable.name}(${args}) { postMessage(${message.toString()}); return await new Promise((resolve) => { resultResolveFunction = resolve; }); } \n`; } else { script += `function ${callable.name}(${args}) { postMessage(${message.toString()}); } \n`; } }); script += "(async function () {\n"; script += code; script += `if (typeof ${eventType} === 'function') { await ${eventType}(); }\n`; script += "postMessage({ type: 'complete', args: [] });\n"; script += "})()\n"; return script; } /** * Generates the blob URL from the given script. * @param script The script to run in the worker. * @private */ private generateWorkerScriptUrl(script: string): string { const blob = new Blob([script], { type: "application/javascript" }); return URL.createObjectURL(blob); } /** * Highlights the block with the given id. * @param id The id of the block to highlight. * @private */ private highlight(id: string): void { if (this.highlightCallback) { this.highlightCallback(id); } } /** * Gets a variable value from the shared state. * @param name The name of the variable. * @returns The value of the variable, or undefined if not set. * @private */ private getVariable(name: string): number { return this.variables.get(name) ?? 0; } /** * Sets a variable value in the shared state. * @param name The name of the variable. * @param value The value to set. * @private */ private setVariable(name: string, value: number): void { this.variables.set(name, value); } }