@webwriter/block-based-code
Version:
Write block-based code (e.g. Scratch) and run it.
252 lines (222 loc) • 6.6 kB
text/typescript
import {
Application, Assets, ColorMatrixFilter, Point, Sprite,
TextureStyle,
} from "pixi.js";
import { sound } from "@pixi/sound";
import { BlockTypes } from "../blockly";
import bunny from "../../assets/bunny.png";
import popSound from "../../assets/sounds/pop.mp3";
import failureSound from "../../assets/sounds/failure.mp3";
import successSound from "../../assets/sounds/success.mp3";
import { StageApplication } from "../types";
import { PixiVirtualMachine } from "./vm";
import { Logger } from "../../utils";
/**
* The PixiApplication class represents a Pixi application.
*/
export class PixiApplication extends StageApplication {
/**
* The virtual machine used by the application.
*/
public override virtualMachine: PixiVirtualMachine;
/**
* The Pixi application.
* @private
*/
private declare application: Application;
/**
* The keyboard event listener.
* @private
*/
private keyboardEventListener: ((event: KeyboardEvent) => void) | null = null;
constructor() {
super();
this.virtualMachine = new PixiVirtualMachine(this.application);
}
/**
* @inheritDoc
*/
public override destroy(): void {
this.virtualMachine.stop();
this.removeKeyboardListener();
this.application.destroy();
super.destroy();
}
/**
* @inheritDoc
*/
public override show(): void {
this.container.appendChild(this.application.canvas);
this.application.render();
this.resize();
}
/**
* @inheritDoc
*/
public override resize(): void {
if (!this.application) return;
// +2 fixes a small gap caused by the canvas' parent having a border
this.application.canvas.style.transform = `
scale(${(this.container.clientWidth + 2) / this.application.canvas.width})
`;
}
/**
* @inheritDoc
*/
protected override createContainer(): void {
super.createContainer();
this.container.style.height = "0";
this.container.style.paddingTop = "calc(100% * 3 / 4)";
}
/**
* @inheritDoc
*/
protected override get specialBlocks(): BlockTypes[] {
return [
"motions:move",
"motions:rotate",
"motions:set_rotation",
"motions:set_x",
"motions:set_y",
"motions:set_xy",
"motions:get_x",
"motions:get_y",
"looks:say",
"looks:say_for_seconds",
"looks:set_color",
"looks:set_background_color",
"sensing:timer",
"sensing:reset_timer",
"sounds:play",
"sounds:play_until_done",
"sounds:stop_all_sounds",
];
}
/**
* @inheritDoc
*/
protected override async init(): Promise<void> {
this.application = new Application();
await this.application.init({
width: 800,
height: 600,
background: "white",
autoStart: false,
});
TextureStyle.defaultOptions.scaleMode = "nearest";
await Assets.load(bunny);
if (!sound.exists("pop")) {
sound.add("pop", popSound);
}
if (!sound.exists("failure")) {
sound.add("failure", failureSound);
}
if (!sound.exists("success")) {
sound.add("success", successSound);
}
this.styleCanvas();
this.addSprite();
this.application.ticker.start();
this.virtualMachine.initSpeechBubble();
}
/**
* Sets up the keyboard event listener.
* @private
*/
private setupKeyboardListener(): void {
this.removeKeyboardListener();
this.keyboardEventListener = (event: KeyboardEvent) => this.handleKeyPress(event);
const target = this.hostElement || document;
target.addEventListener("keydown", this.keyboardEventListener);
}
/**
* Removes the keyboard event listener.
* @private
*/
private removeKeyboardListener(): void {
if (this.keyboardEventListener) {
const target = this.hostElement || document;
target.removeEventListener("keydown", this.keyboardEventListener);
this.keyboardEventListener = null;
}
}
/**
* @inheritDoc
*/
protected override onHostElementSet(): void {
super.onHostElementSet();
this.setupKeyboardListener();
}
/**
* Handles the key press event.
* @param event The keyboard event.
* @private
*/
private handleKeyPress(event: KeyboardEvent): void {
if (!this.executableCode) return;
const path = event.composedPath();
const target = path[0] as HTMLElement;
// Ignore events from input elements (including Blockly fields)
if (this.isInputElement(target)) return;
const { key } = event;
const normalizedKey = key === " " ? "space" : key;
// specific key handler
this.virtualMachine.start(this.executableCode, this.vmDelay, `whenKeyPressed_${normalizedKey}`).catch(() => {
// Ignore errors if the event handler doesn't exist
});
// any key handler
this.virtualMachine.start(this.executableCode, this.vmDelay, "whenKeyPressed_any").catch(() => {
// Ignore errors if the event handler doesn't exist
});
}
/**
* Checks if the target is an input element where we should ignore keyboard events.
* @param target The event target.
* @private
*/
private isInputElement(target: HTMLElement): boolean {
const tagName = target.tagName.toLowerCase();
if (["input", "textarea", "select"].includes(tagName)) return true;
return false;
}
/**
* Styles the Pixi canvas element.
* @private
*/
private styleCanvas(): void {
this.application.canvas.style.position = "absolute";
this.application.canvas.style.transformOrigin = "top left";
this.application.canvas.style.top = "0";
this.application.canvas.style.left = "0";
}
/**
* Adds a sprite to the stage.
* @private
*/
private addSprite(): void {
const sprite = Sprite.from(bunny);
sprite.label = "bunny";
sprite.anchor = new Point(0.5, 0.5);
sprite.x = this.application.canvas.width / 2;
sprite.y = this.application.canvas.height / 2;
sprite.setSize(100);
const filter = new ColorMatrixFilter();
sprite.filters = [filter];
// Make sprite interactive and add click handler
sprite.eventMode = "static";
sprite.on("pointerdown", () => this.handleSpriteClick());
this.application.stage.addChild(sprite);
}
/**
* Handles the sprite click event.
* @private
*/
private handleSpriteClick(): void {
if (this.executableCode) {
// Start the VM with the whenSpriteClicked event
this.virtualMachine.start(this.executableCode, this.vmDelay, "whenSpriteClicked").catch((error) => {
Logger.error("Error starting VM on sprite click:", error);
});
}
}
}