UNPKG

@webwriter/block-based-code

Version:

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

252 lines (222 loc) 6.6 kB
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); }); } } }