@webwriter/block-based-code
Version:
Write block-based code (e.g. Scratch) and run it.
231 lines (204 loc) • 7.54 kB
text/typescript
/**
* 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);
}
}