From be66ffa5ef039b2dedb321d724031d0c5d72f092 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 08:37:59 -0400 Subject: [PATCH 01/12] Fix lints and typechecking errors --- src/Project.ts | 5 +++-- src/Renderer.ts | 4 +--- src/Sprite.ts | 17 +++++++++-------- src/Watcher.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 0d1e3e7..ebdfa19 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -133,10 +133,11 @@ export default class Project { let predicate; switch (trigger.trigger) { case Trigger.TIMER_GREATER_THAN: - predicate = this.timer > trigger.option("VALUE", target)!; + predicate = this.timer > (trigger.option("VALUE", target) as number); break; case Trigger.LOUDNESS_GREATER_THAN: - predicate = this.loudness > trigger.option("VALUE", target)!; + predicate = + this.loudness > (trigger.option("VALUE", target) as number); break; default: throw new Error(`Unimplemented trigger ${String(trigger.trigger)}`); diff --git a/src/Renderer.ts b/src/Renderer.ts index 7b116db..c79cae3 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -163,9 +163,7 @@ export default class Renderer { public _createFramebufferInfo( width: number, height: number, - filtering: - | WebGLRenderingContext["NEAREST"] - | WebGLRenderingContext["LINEAR"], + filtering: number, stencil = false ): FramebufferInfo { // Create an empty texture with this skin's dimensions. diff --git a/src/Sprite.ts b/src/Sprite.ts index 9ae6392..08a0a05 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -97,10 +97,11 @@ type InitialConditions = { }; abstract class SpriteBase { - protected _project!: Project; + // TODO: make this protected and pass it in via the constructor + public _project!: Project; protected _costumeNumber: number; - protected _layerOrder: number; + public _layerOrder: number; public triggers: Trigger[]; public watchers: Partial>; protected costumes: Costume[]; @@ -701,7 +702,7 @@ export class Sprite extends SpriteBase { } while (t < 1); } - ifOnEdgeBounce() { + public ifOnEdgeBounce(): void { const nearestEdge = this.nearestEdge(); if (!nearestEdge) return; const rad = this.scratchToRad(this.direction); @@ -725,7 +726,7 @@ export class Sprite extends SpriteBase { this.positionInFence(); } - positionInFence() { + private positionInFence(): void { // https://github.com/LLK/scratch-vm/blob/develop/src/sprites/rendered-target.js#L949 const fence = this.stage.fence; const bounds = this._project.renderer.getTightBoundingBox(this); @@ -869,7 +870,7 @@ export class Sprite extends SpriteBase { } } - nearestEdge() { + private nearestEdge(): symbol | null { const bounds = this._project.renderer.getTightBoundingBox(this); const { width: stageWidth, height: stageHeight } = this.stage; const distLeft = Math.max(0, stageWidth / 2 + bounds.left); @@ -953,7 +954,7 @@ export class Sprite extends SpriteBase { BOTTOM: Symbol("BOTTOM"), LEFT: Symbol("LEFT"), RIGHT: Symbol("RIGHT"), - TOP: Symbol("TOP") + TOP: Symbol("TOP"), }); } @@ -971,7 +972,7 @@ export class Stage extends SpriteBase { right: number; top: number; bottom: number; - } + }; public constructor(initialConditions: StageInitialConditions, vars = {}) { super(initialConditions, vars); @@ -993,7 +994,7 @@ export class Stage extends SpriteBase { left: -this.width / 2, right: this.width / 2, top: this.height / 2, - bottom: -this.height / 2 + bottom: -this.height / 2, }; // For obsolete counter blocks. diff --git a/src/Watcher.ts b/src/Watcher.ts index 389d82f..2e15a38 100644 --- a/src/Watcher.ts +++ b/src/Watcher.ts @@ -29,7 +29,7 @@ type WatcherOptions = { export default class Watcher { public value: () => WatcherValue; public setValue: (value: number) => void; - private _previousValue: unknown | symbol; + private _previousValue: unknown; private color: Color; private _label!: string; private _x!: number; From e1140942b00c77f32248f9d22ca0990a127bcbdb Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 09:10:52 -0400 Subject: [PATCH 02/12] Add todo for _layerOrder --- src/Sprite.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sprite.ts b/src/Sprite.ts index 08a0a05..809cd82 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -101,6 +101,7 @@ abstract class SpriteBase { public _project!: Project; protected _costumeNumber: number; + // TODO: remove this and just store the sprites in layer order, as Scratch does. public _layerOrder: number; public triggers: Trigger[]; public watchers: Partial>; From 6f8e928341676c02762066595d95b6be1c4f7472 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 09:14:34 -0400 Subject: [PATCH 03/12] Oops, actually lint all the files --- package.json | 2 +- src/renderer/Drawable.ts | 4 +++- src/renderer/ShaderManager.ts | 7 +------ src/renderer/Skin.ts | 4 +--- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1414194..da478a9 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "rollup -c", "dev": "rollup -c --watch", - "lint": "eslint \"./src/**.ts\"", + "lint": "eslint \"./src/**/*.ts\"", "prepare": "npm run build" }, "devDependencies": { diff --git a/src/renderer/Drawable.ts b/src/renderer/Drawable.ts index e1be5bd..96ee474 100644 --- a/src/renderer/Drawable.ts +++ b/src/renderer/Drawable.ts @@ -410,7 +410,9 @@ export default class Drawable { private _warnBadSize(description: string, treating: string): void { if (!this._warnedBadSize) { const { name } = this._sprite.constructor; - console.warn(`Expected a number, sprite ${name} size is ${description}. Treating as ${treating}.`); + console.warn( + `Expected a number, sprite ${name} size is ${description}. Treating as ${treating}.` + ); this._warnedBadSize = true; } } diff --git a/src/renderer/ShaderManager.ts b/src/renderer/ShaderManager.ts index 8ed6849..89dd3d1 100644 --- a/src/renderer/ShaderManager.ts +++ b/src/renderer/ShaderManager.ts @@ -59,12 +59,7 @@ class ShaderManager { } // Creates and compiles a vertex or fragment shader from the given source code. - private _createShader( - source: string, - type: - | WebGLRenderingContext["FRAGMENT_SHADER"] - | WebGLRenderingContext["VERTEX_SHADER"] - ): WebGLShader { + private _createShader(source: string, type: number): WebGLShader { const gl = this.gl; const shader = gl.createShader(type); if (!shader) throw new Error("Could not create shader."); diff --git a/src/renderer/Skin.ts b/src/renderer/Skin.ts index 0f29528..17e3e0f 100644 --- a/src/renderer/Skin.ts +++ b/src/renderer/Skin.ts @@ -28,9 +28,7 @@ export default abstract class Skin { // Helper function to create a texture from an image and handle all the boilerplate. protected _makeTexture( image: HTMLImageElement | HTMLCanvasElement | null, - filtering: - | WebGLRenderingContext["NEAREST"] - | WebGLRenderingContext["LINEAR"] + filtering: number ): WebGLTexture { const gl = this.gl; const glTexture = gl.createTexture(); From 01911477d86d0d6f896acc11b2a52800c785d451 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 10:23:35 -0400 Subject: [PATCH 04/12] Maintain list of targets in layer order This matches how Scratch behaves, and means we don't have to mess around with _layerOrder anymore. --- src/Project.ts | 103 +++++++++++++++++++++++++++++++++++------------- src/Renderer.ts | 50 +++++++++++------------ src/Sprite.ts | 53 +++++++++++++------------ 3 files changed, 126 insertions(+), 80 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index ebdfa19..a022fe3 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -3,7 +3,7 @@ import Renderer from "./Renderer"; import Input from "./Input"; import LoudnessHandler from "./Loudness"; import Sound from "./Sound"; -import type { Stage, Sprite } from "./Sprite"; +import { Stage, Sprite } from "./Sprite"; type TriggerWithTarget = { target: Sprite | Stage; @@ -13,6 +13,13 @@ type TriggerWithTarget = { export default class Project { public stage: Stage; public sprites: Partial>; + /** + * All rendered targets (the stage, sprites, and clones), in layer order. + * This is kept private so that nobody can improperly modify it. The only way + * to add or remove targets is via the appropriate methods, and iteration can + * be done with {@link forEachTarget}. + */ + private targets: (Sprite | Stage)[]; public renderer: Renderer; public input: Input; @@ -34,12 +41,29 @@ export default class Project { this.stage = stage; this.sprites = sprites; - Object.freeze(sprites); // Prevent adding/removing sprites while project is running + this.targets = [ + stage, + ...Object.values(this.sprites as Record), + ]; + this.targets.sort((a, b) => { + // There should only ever be one stage, but it's best to maintain a total + // ordering to avoid weird sorting-algorithm stuff from happening if + // there's more than one + if (a instanceof Stage && !(b instanceof Stage)) { + return -1; + } + if (b instanceof Stage && !(a instanceof Stage)) { + return 1; + } - for (const sprite of this.spritesAndClones) { - sprite._project = this; + return a.getInitialLayerOrder() - b.getInitialLayerOrder(); + }); + for (const target of this.targets) { + target.clearInitialLayerOrder(); + target._project = this; } - this.stage._project = this; + + Object.freeze(sprites); // Prevent adding/removing sprites while project is running this.renderer = new Renderer(this, null); this.input = new Input(this.stage, this.renderer.stage, (key) => { @@ -77,7 +101,7 @@ export default class Project { void Sound.audioContext.resume(); } - let clickedSprite = this.renderer.pick(this.spritesAndClones, { + let clickedSprite = this.renderer.pick(this.targets, { x: this.input.mouse.x, y: this.input.mouse.y, }); @@ -113,8 +137,7 @@ export default class Project { triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean ): TriggerWithTarget[] { const matchingTriggers: TriggerWithTarget[] = []; - const targets = this.spritesAndStage; - for (const target of targets) { + for (const target of this.targets) { const matchingTargetTriggers = target.triggers.filter((tr) => triggerMatches(tr, target) ); @@ -198,15 +221,13 @@ export default class Project { this.stopAllSounds(); this.runningTriggers = []; - for (const spriteName in this.sprites) { - const sprite = this.sprites[spriteName]!; - sprite.clones = []; - } + this.filterSprites((sprite) => { + if (!sprite.isOriginal) return false; - for (const sprite of this.spritesAndStage) { sprite.effects.clear(); sprite.audioEffects.clear(); - } + return true; + }); } const matchingTriggers = this._matchingTriggers((tr, target) => @@ -237,14 +258,41 @@ export default class Project { ); } - public get spritesAndClones(): Sprite[] { - return Object.values(this.sprites) - .flatMap((sprite) => sprite!.andClones()) - .sort((a, b) => a._layerOrder - b._layerOrder); + public addSprite(sprite: Sprite, behind?: Sprite): void { + if (behind) { + const currentIndex = this.targets.indexOf(behind); + this.targets.splice(currentIndex, 0, sprite); + } else { + this.targets.push(sprite); + } + } + + public removeSprite(sprite: Sprite): void { + const index = this.targets.indexOf(sprite); + if (index === -1) return; + + this.targets.splice(index, 1); + this.cleanupSprite(sprite); + } + + public filterSprites(predicate: (sprite: Sprite) => boolean): void { + let nextKeptSpriteIndex = 0; + for (let i = 0; i < this.targets.length; i++) { + const target = this.targets[i]; + if (target instanceof Stage || predicate(target)) { + this.targets[nextKeptSpriteIndex] = target; + nextKeptSpriteIndex++; + } else { + this.cleanupSprite(target); + } + } + this.targets.length = nextKeptSpriteIndex; } - public get spritesAndStage(): (Sprite | Stage)[] { - return [...this.spritesAndClones, this.stage]; + private cleanupSprite(sprite: Sprite): void { + this.runningTriggers = this.runningTriggers.filter( + ({ target }) => target !== sprite + ); } public changeSpriteLayer( @@ -252,7 +300,7 @@ export default class Project { layerDelta: number, relativeToSprite = sprite ): void { - const spritesArray = this.spritesAndClones; + const spritesArray = this.targets; const originalIndex = spritesArray.indexOf(sprite); const relativeToIndex = spritesArray.indexOf(relativeToSprite); @@ -264,17 +312,16 @@ export default class Project { // Remove sprite from originalIndex and insert at newIndex spritesArray.splice(originalIndex, 1); spritesArray.splice(newIndex, 0, sprite); + } - // spritesArray is sorted correctly, but to influence - // the actual order of the sprites we need to update - // each one's _layerOrder property. - spritesArray.forEach((sprite, index) => { - sprite._layerOrder = index + 1; - }); + public forEachTarget(callback: (target: Sprite | Stage) => void): void { + for (const target of this.targets) { + callback(target); + } } public stopAllSounds(): void { - for (const target of this.spritesAndStage) { + for (const target of this.targets) { target.stopAllOfMySounds(); } } diff --git a/src/Renderer.ts b/src/Renderer.ts index c79cae3..247d7ce 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -303,37 +303,33 @@ export default class Renderer { (filter && !filter(layer)) ); - // Stage - if (shouldIncludeLayer(this.project.stage)) { - this.renderSprite(this.project.stage, options); - } + this.project.forEachTarget((target) => { + // TODO: just make a `visible` getter for Stage to avoid this rigmarole + const visible = "visible" in target ? target.visible : true; - // Pen layer - if (shouldIncludeLayer(this._penSkin)) { - const penMatrix = Matrix.create(); - Matrix.scale( - penMatrix, - penMatrix, - this._penSkin.width, - -this._penSkin.height - ); - Matrix.translate(penMatrix, penMatrix, -0.5, -0.5); + if (shouldIncludeLayer(target) && visible) { + this.renderSprite(target, options); + } - this._renderSkin( - this._penSkin, - options.drawMode, - penMatrix, - 1 /* scale */ - ); - } + // Draw the pen layer in front of the stage + if (target instanceof Stage && shouldIncludeLayer(this._penSkin)) { + const penMatrix = Matrix.create(); + Matrix.scale( + penMatrix, + penMatrix, + this._penSkin.width, + -this._penSkin.height + ); + Matrix.translate(penMatrix, penMatrix, -0.5, -0.5); - // Sprites + clones - for (const sprite of this.project.spritesAndClones) { - // Stage doesn't have "visible" defined, so check if it's strictly false - if (shouldIncludeLayer(sprite) && sprite.visible !== false) { - this.renderSprite(sprite, options); + this._renderSkin( + this._penSkin, + options.drawMode, + penMatrix, + 1 /* scale */ + ); } - } + }); } private _updateStageSize(): void { diff --git a/src/Sprite.ts b/src/Sprite.ts index 809cd82..c3fbf7a 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -102,7 +102,7 @@ abstract class SpriteBase { protected _costumeNumber: number; // TODO: remove this and just store the sprites in layer order, as Scratch does. - public _layerOrder: number; + private _initialLayerOrder: number | null; public triggers: Trigger[]; public watchers: Partial>; protected costumes: Costume[]; @@ -118,7 +118,7 @@ abstract class SpriteBase { // TODO: pass project in here, ideally const { costumeNumber, layerOrder = 0 } = initialConditions; this._costumeNumber = costumeNumber; - this._layerOrder = layerOrder; + this._initialLayerOrder = layerOrder; this.triggers = []; this.watchers = {}; @@ -136,6 +136,20 @@ abstract class SpriteBase { this._vars = vars; } + public getInitialLayerOrder(): number { + const order = this._initialLayerOrder; + if (order === null) { + throw new Error( + "getInitialLayerOrder should only be called once, when the project is initialized" + ); + } + return order; + } + + public clearInitialLayerOrder(): void { + this._initialLayerOrder = null; + } + protected getSoundsPlayedByMe(): Sound[] { return this.sounds.filter((sound) => this.effectChain.isTargetOf(sound)); } @@ -523,8 +537,7 @@ export class Sprite extends SpriteBase { public size: number; public visible: boolean; - private parent: this | null; - public clones: this[]; + private original: this; private _penDown: boolean; public penSize: number; @@ -555,8 +568,7 @@ export class Sprite extends SpriteBase { this.size = size; this.visible = visible; - this.parent = null; - this.clones = []; + this.original = this; this._penDown = penDown || false; this.penSize = penSize || 1; @@ -569,6 +581,10 @@ export class Sprite extends SpriteBase { }; } + public get isOriginal(): boolean { + return this.original === this; + } + public *askAndWait(question: string): Yielding { if (this._speechBubble) { this.say(""); @@ -599,21 +615,16 @@ export class Sprite extends SpriteBase { // Clones inherit audio effects from the original sprite, for some reason. // Couldn't explain it, but that's the behavior in Scratch 3.0. - // eslint-disable-next-line @typescript-eslint/no-this-alias - let original = this; - while (original.parent) { - original = original.parent; - } - clone.effectChain = original.effectChain.clone({ + clone.effectChain = this.original.effectChain.clone({ getNonPatchSoundList: clone.getSoundsPlayedByMe.bind(clone), }); // Make a new audioEffects interface which acts on the cloned effect chain. clone.audioEffects = new AudioEffectMap(clone.effectChain); - clone.clones = []; - clone.parent = this; - this.clones.push(clone); + clone.original = this.original; + + this._project.addSprite(clone, this); // Trigger CLONE_START: const triggers = clone.triggers.filter((tr) => @@ -625,17 +636,9 @@ export class Sprite extends SpriteBase { } public deleteThisClone(): void { - if (this.parent === null) return; - - this.parent.clones = this.parent.clones.filter((clone) => clone !== this); - - this._project.runningTriggers = this._project.runningTriggers.filter( - ({ target }) => target !== this - ); - } + if (this.isOriginal) return; - public andClones(): this[] { - return [this, ...this.clones.flatMap((clone) => clone.andClones())]; + this._project.removeSprite(this); } public get direction(): number { From 104c995ed5dc43c25d2721a010fefa090d8ec184 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 10:24:41 -0400 Subject: [PATCH 05/12] Start triggers in top-down order This matches how Scratch does it. --- src/Project.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Project.ts b/src/Project.ts index a022fe3..f2a2641 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -137,7 +137,9 @@ export default class Project { triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean ): TriggerWithTarget[] { const matchingTriggers: TriggerWithTarget[] = []; - for (const target of this.targets) { + // Iterate over targets in top-down order, as Scratch does + for (let i = this.targets.length - 1; i >= 0; i--) { + const target = this.targets[i]; const matchingTargetTriggers = target.triggers.filter((tr) => triggerMatches(tr, target) ); From 7d6134c97b1873a73d26d41d4b493bb858594c23 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 11:15:50 -0400 Subject: [PATCH 06/12] Convert trigger promises to generators fireTrigger now returns a list of started scripts instead of a promise that resolves when they're finished running. We can wait for those scripts to finish using the new waitForTriggers function, which will yield in a loop until they're done running. --- src/Project.ts | 24 +++++++++++++----------- src/Sprite.ts | 41 +++++++++++++++++++++++++---------------- src/Trigger.ts | 37 +++++++++++++++++++------------------ 3 files changed, 57 insertions(+), 45 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index f2a2641..96e3d0f 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -1,11 +1,11 @@ -import Trigger, { TriggerOptions } from "./Trigger"; +import Trigger, { RunStatus, TriggerOptions } from "./Trigger"; import Renderer from "./Renderer"; import Input from "./Input"; import LoudnessHandler from "./Loudness"; import Sound from "./Sound"; import { Stage, Sprite } from "./Sprite"; -type TriggerWithTarget = { +export type TriggerWithTarget = { target: Sprite | Stage; trigger: Trigger; }; @@ -67,7 +67,7 @@ export default class Project { this.renderer = new Renderer(this, null); this.input = new Input(this.stage, this.renderer.stage, (key) => { - void this.fireTrigger(Trigger.KEY_PRESSED, { key }); + this.fireTrigger(Trigger.KEY_PRESSED, { key }); }); this.loudnessHandler = new LoudnessHandler(); @@ -116,7 +116,7 @@ export default class Project { } } - void this._startTriggers(matchingTriggers); + this._startTriggers(matchingTriggers); }); } @@ -193,7 +193,7 @@ export default class Project { // Remove finished triggers this.runningTriggers = this.runningTriggers.filter( - ({ trigger }) => !trigger.done + ({ trigger }) => trigger.status !== RunStatus.DONE ); } @@ -216,7 +216,10 @@ export default class Project { this.render(); } - public fireTrigger(trigger: symbol, options?: TriggerOptions): Promise { + public fireTrigger( + trigger: symbol, + options?: TriggerOptions + ): TriggerWithTarget[] { // Special trigger behaviors if (trigger === Trigger.GREEN_FLAG) { this.restartTimer(); @@ -236,11 +239,12 @@ export default class Project { tr.matches(trigger, options, target) ); - return this._startTriggers(matchingTriggers); + this._startTriggers(matchingTriggers); + return matchingTriggers; } // TODO: add a way to start clone triggers from fireTrigger then make this private - public async _startTriggers(triggers: TriggerWithTarget[]): Promise { + public _startTriggers(triggers: TriggerWithTarget[]): void { // Only add these triggers to this.runningTriggers if they're not already there. // TODO: if the triggers are already running, they'll be restarted but their execution order is unchanged. // Does that match Scratch's behavior? @@ -254,10 +258,8 @@ export default class Project { ) { this.runningTriggers.push(trigger); } + trigger.trigger.start(trigger.target); } - await Promise.all( - triggers.map(({ trigger, target }) => trigger.start(target)) - ); } public addSprite(sprite: Sprite, behind?: Sprite): void { diff --git a/src/Sprite.ts b/src/Sprite.ts index c3fbf7a..e9c7d0f 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -1,5 +1,5 @@ import Color from "./Color"; -import Trigger from "./Trigger"; +import Trigger, { RunStatus } from "./Trigger"; import Sound, { EffectChain, AudioEffectMap } from "./Sound"; import Costume from "./Costume"; import type { Mouse } from "./Input"; @@ -8,6 +8,7 @@ import type Watcher from "./Watcher"; import type { Yielding } from "./lib/yielding"; import { effectNames } from "./renderer/effectInfo"; +import { TriggerWithTarget } from "./Project"; type Effects = { [x in typeof effectNames[number]]: number; @@ -374,19 +375,25 @@ abstract class SpriteBase { } } - public broadcast(name: string): Promise { - return this._project.fireTrigger(Trigger.BROADCAST, { name }); + public *waitForTriggers(triggers: TriggerWithTarget[]): Yielding { + while (true) { + for (const trigger of triggers) { + if (trigger.trigger.status !== RunStatus.DONE) { + yield; + } + } + break; + } } - public *broadcastAndWait(name: string): Yielding { - let running = true; - void this.broadcast(name).then(() => { - running = false; - }); + public broadcast(name: string): Yielding { + return this.waitForTriggers( + this._project.fireTrigger(Trigger.BROADCAST, { name }) + ); + } - while (running) { - yield; - } + public *broadcastAndWait(name: string): Yielding { + yield* this.broadcast(name); } public clearPen(): void { @@ -1005,10 +1012,12 @@ export class Stage extends SpriteBase { this.__counter = 0; } - public fireBackdropChanged(): Promise { - return this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { - backdrop: this.costume.name, - }); + public fireBackdropChanged(): Yielding { + return this.waitForTriggers( + this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { + backdrop: this.costume.name, + }) + ); } public get costumeNumber(): number { @@ -1017,6 +1026,6 @@ export class Stage extends SpriteBase { public set costumeNumber(number) { super.costumeNumber = number; - void this.fireBackdropChanged(); + this.fireBackdropChanged(); } } diff --git a/src/Trigger.ts b/src/Trigger.ts index 98a82d7..a8fbeae 100644 --- a/src/Trigger.ts +++ b/src/Trigger.ts @@ -8,13 +8,24 @@ type TriggerOption = type TriggerOptions = Partial>; +export enum RunStatus { + /** This script is currently running. */ + RUNNING, + /** + * This script is waiting for a promise, or waiting for other scripts. + * @todo This requires runtime support. + */ + // PARKED, + /** This script is finished running. */ + DONE, +} + export default class Trigger { public trigger; private options: TriggerOptions; private _script: GeneratorFunction; private _runningScript: Generator | undefined; - public done: boolean; - private stop: () => void; + public status: RunStatus; public constructor( trigger: symbol, @@ -37,9 +48,7 @@ export default class Trigger { this._script = script; } - this.done = false; - // eslint-disable-next-line @typescript-eslint/no-empty-function - this.stop = () => {}; + this.status = RunStatus.DONE; } public get isEdgeActivated(): boolean { @@ -77,24 +86,16 @@ export default class Trigger { return true; } - public start(target: Sprite | Stage): Promise { - this.stop(); - - this.done = false; + public start(target: Sprite | Stage): void { + this.status = RunStatus.RUNNING; this._runningScript = this._script.call(target); - - return new Promise((resolve) => { - this.stop = (): void => { - this.done = true; - resolve(); - }; - }); } public step(): void { if (!this._runningScript) return; - this.done = !!this._runningScript.next().done; - if (this.done) this.stop(); + if (this._runningScript.next().done) { + this.status = RunStatus.DONE; + } } public clone(): Trigger { From 0d337a728a1be971e64025b26f8aff4a7fb7f2d4 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 11:27:17 -0400 Subject: [PATCH 07/12] Separate some Trigger state into new Thread class Data that pertains to the state of a *running* script is now part of a new Thread class, meaning Trigger no longer stores any state of its own. --- src/Project.ts | 76 ++++++++++++++++++++++++-------------------------- src/Sprite.ts | 16 +++++------ src/Thread.ts | 39 ++++++++++++++++++++++++++ src/Trigger.ts | 28 ++----------------- 4 files changed, 86 insertions(+), 73 deletions(-) create mode 100644 src/Thread.ts diff --git a/src/Project.ts b/src/Project.ts index 96e3d0f..79e6dc1 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -1,11 +1,12 @@ -import Trigger, { RunStatus, TriggerOptions } from "./Trigger"; +import Trigger, { TriggerOptions } from "./Trigger"; import Renderer from "./Renderer"; import Input from "./Input"; import LoudnessHandler from "./Loudness"; import Sound from "./Sound"; import { Stage, Sprite } from "./Sprite"; +import Thread, { ThreadStatus } from "./Thread"; -export type TriggerWithTarget = { +type TriggerWithTarget = { target: Sprite | Stage; trigger: Trigger; }; @@ -26,7 +27,7 @@ export default class Project { private loudnessHandler: LoudnessHandler; private _cachedLoudness: number | null; - public runningTriggers: TriggerWithTarget[]; + public threads: Thread[]; public answer: string | null; private timerStart!: Date; @@ -74,7 +75,7 @@ export default class Project { // Only update loudness once per step. this._cachedLoudness = null; - this.runningTriggers = []; + this.threads = []; this._prevStepTriggerPredicates = new WeakMap(); this.restartTimer(); @@ -109,14 +110,14 @@ export default class Project { clickedSprite = this.stage; } - const matchingTriggers: TriggerWithTarget[] = []; + const matchingTriggers = []; for (const trigger of clickedSprite.triggers) { if (trigger.matches(Trigger.CLICKED, {}, clickedSprite)) { matchingTriggers.push({ trigger, target: clickedSprite }); } } - this._startTriggers(matchingTriggers); + this._startThreads(matchingTriggers); }); } @@ -136,7 +137,7 @@ export default class Project { private _matchingTriggers( triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean ): TriggerWithTarget[] { - const matchingTriggers: TriggerWithTarget[] = []; + const matchingTriggers = []; // Iterate over targets in top-down order, as Scratch does for (let i = this.targets.length - 1; i >= 0; i--) { const target = this.targets[i]; @@ -175,25 +176,25 @@ export default class Project { // The predicate evaluated to false last time and true this time // Activate the trigger if (!prevPredicate && predicate) { - triggersToStart.push(triggerWithTarget); + triggersToStart.push({ trigger, target }); } } - void this._startTriggers(triggersToStart); + void this._startThreads(triggersToStart); } private step(): void { this._cachedLoudness = null; this._stepEdgeActivatedTriggers(); - // Step all triggers - const alreadyRunningTriggers = this.runningTriggers; - for (let i = 0; i < alreadyRunningTriggers.length; i++) { - alreadyRunningTriggers[i].trigger.step(); + // Step all threads + const threads = this.threads; + for (let i = 0; i < threads.length; i++) { + threads[i].step(); } - // Remove finished triggers - this.runningTriggers = this.runningTriggers.filter( - ({ trigger }) => trigger.status !== RunStatus.DONE + // Remove finished threads + this.threads = this.threads.filter( + (thread) => thread.status !== ThreadStatus.DONE ); } @@ -216,15 +217,12 @@ export default class Project { this.render(); } - public fireTrigger( - trigger: symbol, - options?: TriggerOptions - ): TriggerWithTarget[] { + public fireTrigger(trigger: symbol, options?: TriggerOptions): Thread[] { // Special trigger behaviors if (trigger === Trigger.GREEN_FLAG) { this.restartTimer(); this.stopAllSounds(); - this.runningTriggers = []; + this.threads = []; this.filterSprites((sprite) => { if (!sprite.isOriginal) return false; @@ -239,27 +237,29 @@ export default class Project { tr.matches(trigger, options, target) ); - this._startTriggers(matchingTriggers); - return matchingTriggers; + return this._startThreads(matchingTriggers); } // TODO: add a way to start clone triggers from fireTrigger then make this private - public _startTriggers(triggers: TriggerWithTarget[]): void { - // Only add these triggers to this.runningTriggers if they're not already there. - // TODO: if the triggers are already running, they'll be restarted but their execution order is unchanged. + public _startThreads(triggers: TriggerWithTarget[]): Thread[] { + const startedThreads = []; + // Only add these threads to this.threads if they're not already there. + // TODO: if the threads are already running, they'll be restarted but their execution order is unchanged. // Does that match Scratch's behavior? - for (const trigger of triggers) { - if ( - !this.runningTriggers.find( - (runningTrigger) => - trigger.trigger === runningTrigger.trigger && - trigger.target === runningTrigger.target - ) - ) { - this.runningTriggers.push(trigger); + for (const { trigger, target } of triggers) { + const existingThread = this.threads.find( + (thread) => thread.trigger === trigger && thread.target === target + ); + if (existingThread) { + existingThread.restart(); + startedThreads.push(existingThread); + } else { + const thread = new Thread(trigger, target); + this.threads.push(thread); + startedThreads.push(thread); } - trigger.trigger.start(trigger.target); } + return startedThreads; } public addSprite(sprite: Sprite, behind?: Sprite): void { @@ -294,9 +294,7 @@ export default class Project { } private cleanupSprite(sprite: Sprite): void { - this.runningTriggers = this.runningTriggers.filter( - ({ target }) => target !== sprite - ); + this.threads = this.threads.filter(({ target }) => target !== sprite); } public changeSpriteLayer( diff --git a/src/Sprite.ts b/src/Sprite.ts index e9c7d0f..e6f4278 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -1,14 +1,14 @@ import Color from "./Color"; -import Trigger, { RunStatus } from "./Trigger"; +import Trigger from "./Trigger"; import Sound, { EffectChain, AudioEffectMap } from "./Sound"; import Costume from "./Costume"; import type { Mouse } from "./Input"; import type Project from "./Project"; +import Thread, { ThreadStatus } from "./Thread"; import type Watcher from "./Watcher"; import type { Yielding } from "./lib/yielding"; import { effectNames } from "./renderer/effectInfo"; -import { TriggerWithTarget } from "./Project"; type Effects = { [x in typeof effectNames[number]]: number; @@ -375,10 +375,10 @@ abstract class SpriteBase { } } - public *waitForTriggers(triggers: TriggerWithTarget[]): Yielding { + public *waitForThreads(threads: Thread[]): Yielding { while (true) { - for (const trigger of triggers) { - if (trigger.trigger.status !== RunStatus.DONE) { + for (const thread of threads) { + if (thread.status !== ThreadStatus.DONE) { yield; } } @@ -387,7 +387,7 @@ abstract class SpriteBase { } public broadcast(name: string): Yielding { - return this.waitForTriggers( + return this.waitForThreads( this._project.fireTrigger(Trigger.BROADCAST, { name }) ); } @@ -637,7 +637,7 @@ export class Sprite extends SpriteBase { const triggers = clone.triggers.filter((tr) => tr.matches(Trigger.CLONE_START, {}, clone) ); - void this._project._startTriggers( + void this._project._startThreads( triggers.map((trigger) => ({ trigger, target: clone })) ); } @@ -1013,7 +1013,7 @@ export class Stage extends SpriteBase { } public fireBackdropChanged(): Yielding { - return this.waitForTriggers( + return this.waitForThreads( this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { backdrop: this.costume.name, }) diff --git a/src/Thread.ts b/src/Thread.ts new file mode 100644 index 0000000..e9b5b91 --- /dev/null +++ b/src/Thread.ts @@ -0,0 +1,39 @@ +import { Sprite, Stage } from "./Sprite"; +import Trigger from "./Trigger"; + +export enum ThreadStatus { + /** This script is currently running. */ + RUNNING, + /** + * This script is waiting for a promise, or waiting for other scripts. + * @todo This requires runtime support. + */ + // PARKED, + /** This script is finished running. */ + DONE, +} + +export default class Thread { + public target: Sprite | Stage; + public trigger: Trigger; + public status: ThreadStatus; + private runningScript: Generator; + + public constructor(trigger: Trigger, target: Sprite | Stage) { + this.runningScript = trigger.startScript(target); + this.trigger = trigger; + this.target = target; + this.status = ThreadStatus.RUNNING; + } + + public step(): void { + if (this.runningScript.next().done) { + this.status = ThreadStatus.DONE; + } + } + + public restart(): void { + this.runningScript = this.trigger.startScript(this.target); + this.status = ThreadStatus.RUNNING; + } +} diff --git a/src/Trigger.ts b/src/Trigger.ts index a8fbeae..123a4e5 100644 --- a/src/Trigger.ts +++ b/src/Trigger.ts @@ -8,24 +8,10 @@ type TriggerOption = type TriggerOptions = Partial>; -export enum RunStatus { - /** This script is currently running. */ - RUNNING, - /** - * This script is waiting for a promise, or waiting for other scripts. - * @todo This requires runtime support. - */ - // PARKED, - /** This script is finished running. */ - DONE, -} - export default class Trigger { public trigger; private options: TriggerOptions; private _script: GeneratorFunction; - private _runningScript: Generator | undefined; - public status: RunStatus; public constructor( trigger: symbol, @@ -47,8 +33,6 @@ export default class Trigger { this.options = optionsOrScript as TriggerOptions; this._script = script; } - - this.status = RunStatus.DONE; } public get isEdgeActivated(): boolean { @@ -86,16 +70,8 @@ export default class Trigger { return true; } - public start(target: Sprite | Stage): void { - this.status = RunStatus.RUNNING; - this._runningScript = this._script.call(target); - } - - public step(): void { - if (!this._runningScript) return; - if (this._runningScript.next().done) { - this.status = RunStatus.DONE; - } + public startScript(target: Sprite | Stage): Generator { + return this._script.call(target); } public clone(): Trigger { From d0e97946413fd13dc4351687d17958fb74c693bd Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 12:15:06 -0400 Subject: [PATCH 08/12] Implement redraw requests These don't do anything right now, but will be used to determine whether to keep stepping threads or yield until next frame. --- src/Project.ts | 12 +++++-- src/Sprite.ts | 88 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 79e6dc1..0e4da0d 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -27,7 +27,8 @@ export default class Project { private loudnessHandler: LoudnessHandler; private _cachedLoudness: number | null; - public threads: Thread[]; + private threads: Thread[]; + private redrawRequested: boolean; public answer: string | null; private timerStart!: Date; @@ -61,7 +62,7 @@ export default class Project { }); for (const target of this.targets) { target.clearInitialLayerOrder(); - target._project = this; + target.setProject(this); } Object.freeze(sprites); // Prevent adding/removing sprites while project is running @@ -76,6 +77,7 @@ export default class Project { this._cachedLoudness = null; this.threads = []; + this.redrawRequested = false; this._prevStepTriggerPredicates = new WeakMap(); this.restartTimer(); @@ -196,6 +198,8 @@ export default class Project { this.threads = this.threads.filter( (thread) => thread.status !== ThreadStatus.DONE ); + + this.redrawRequested = false; } private render(): void { @@ -322,6 +326,10 @@ export default class Project { } } + public requestRedraw(): void { + this.redrawRequested = true; + } + public stopAllSounds(): void { for (const target of this.targets) { target.stopAllOfMySounds(); diff --git a/src/Sprite.ts b/src/Sprite.ts index e6f4278..091a904 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -20,6 +20,8 @@ type Effects = { export class _EffectMap implements Effects { public _bitmask: number; private _effectValues: Record; + public _project: Project | null; + private _target: SpriteBase; // TODO: TypeScript can't automatically infer these public color!: number; public fisheye!: number; @@ -29,8 +31,10 @@ export class _EffectMap implements Effects { public brightness!: number; public ghost!: number; - public constructor() { + public constructor(project: Project | null, target: SpriteBase) { this._bitmask = 0; + this._project = project; + this._target = target; this._effectValues = { color: 0, fisheye: 0, @@ -59,13 +63,17 @@ export class _EffectMap implements Effects { // Otherwise, set its bit to 1. this._bitmask = this._bitmask | (1 << i); } + + if ("visible" in this._target ? this._target.visible : true) { + this._project?.requestRedraw(); + } }, }); } } - public _clone(): _EffectMap { - const m = new _EffectMap(); + public _clone(newTarget: Sprite | Stage): _EffectMap { + const m = new _EffectMap(this._project, newTarget); for (const effectName of Object.keys( this._effectValues ) as (keyof typeof this._effectValues)[]) { @@ -81,6 +89,10 @@ export class _EffectMap implements Effects { this._effectValues[effectName] = 0; } this._bitmask = 0; + + if ("visible" in this._target ? this._target.visible : true) { + this._project?.requestRedraw(); + } } } @@ -99,7 +111,7 @@ type InitialConditions = { abstract class SpriteBase { // TODO: make this protected and pass it in via the constructor - public _project!: Project; + protected _project!: Project; protected _costumeNumber: number; // TODO: remove this and just store the sprites in layer order, as Scratch does. @@ -131,12 +143,17 @@ abstract class SpriteBase { }); this.effectChain.connect(Sound.audioContext.destination); - this.effects = new _EffectMap(); + this.effects = new _EffectMap(this._project, this); this.audioEffects = new AudioEffectMap(this.effectChain); this._vars = vars; } + public setProject(project: Project): void { + this._project = project; + this.effects._project = project; + } + public getInitialLayerOrder(): number { const order = this._initialLayerOrder; if (order === null) { @@ -177,6 +194,10 @@ abstract class SpriteBase { } else { this._costumeNumber = 0; } + + if ("visible" in this ? this.visible : true) { + this._project.requestRedraw(); + } } public set costume(costume: number | string | Costume) { @@ -319,6 +340,7 @@ abstract class SpriteBase { public *wait(secs: number): Yielding { const endTime = new Date(); endTime.setMilliseconds(endTime.getMilliseconds() + secs * 1000); + this._project.requestRedraw(); while (new Date() < endTime) { yield; } @@ -398,6 +420,7 @@ abstract class SpriteBase { public clearPen(): void { this._project.renderer.clearPen(); + this._project.requestRedraw(); } public *askAndWait(question: string): Yielding { @@ -540,9 +563,9 @@ export class Sprite extends SpriteBase { private _x: number; private _y: number; private _direction: number; - public rotationStyle: RotationStyle; - public size: number; - public visible: boolean; + private _rotationStyle: RotationStyle; + private _size: number; + private _visible: boolean; private original: this; @@ -570,10 +593,10 @@ export class Sprite extends SpriteBase { this._x = x; this._y = y; this._direction = direction; - this.rotationStyle = rotationStyle || Sprite.RotationStyle.ALL_AROUND; + this._rotationStyle = rotationStyle || Sprite.RotationStyle.ALL_AROUND; this._costumeNumber = costumeNumber; - this.size = size; - this.visible = visible; + this._size = size; + this._visible = visible; this.original = this; @@ -618,7 +641,7 @@ export class Sprite extends SpriteBase { timeout: null, }; - clone.effects = this.effects._clone(); + clone.effects = this.effects._clone(clone); // Clones inherit audio effects from the original sprite, for some reason. // Couldn't explain it, but that's the behavior in Scratch 3.0. @@ -646,6 +669,7 @@ export class Sprite extends SpriteBase { if (this.isOriginal) return; this._project.removeSprite(this); + if (this._visible) this._project.requestRedraw(); } public get direction(): number { @@ -654,6 +678,34 @@ export class Sprite extends SpriteBase { public set direction(dir) { this._direction = this.normalizeDeg(dir); + if (this._visible) this._project.requestRedraw(); + } + + public get rotationStyle(): RotationStyle { + return this._rotationStyle; + } + + public set rotationStyle(style) { + this._rotationStyle = style; + if (this._visible) this._project.requestRedraw(); + } + + public get size(): number { + return this._size; + } + + public set size(size) { + this._size = size; // TODO: clamp size like Scratch does + if (this._visible) this._project.requestRedraw(); + } + + public get visible(): boolean { + return this._visible; + } + + public set visible(visible) { + this._visible = visible; + if (visible) this._project.requestRedraw(); } public goto(x: number, y: number): void { @@ -670,6 +722,10 @@ export class Sprite extends SpriteBase { this._x = x; this._y = y; + + if (this.penDown || this.visible) { + this._project.requestRedraw(); + } } public get x(): number { @@ -790,6 +846,7 @@ export class Sprite extends SpriteBase { ); } this._penDown = penDown; + if (penDown) this._project.requestRedraw(); } public get penColor(): Color { @@ -808,6 +865,7 @@ export class Sprite extends SpriteBase { public stamp(): void { this._project.renderer.stamp(this); + this._project.requestRedraw(); } public touching( @@ -916,11 +974,13 @@ export class Sprite extends SpriteBase { public say(text: string): void { if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); this._speechBubble = { text: String(text), style: "say", timeout: null }; + this._project.requestRedraw(); } public think(text: string): void { if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); this._speechBubble = { text: String(text), style: "think", timeout: null }; + this._project.requestRedraw(); } public *sayAndWait(text: string, seconds: number): Yielding { @@ -936,7 +996,9 @@ export class Sprite extends SpriteBase { speechBubble.timeout = timeout; this._speechBubble = speechBubble; + this._project.requestRedraw(); while (!done) yield; + this._project.requestRedraw(); } public *thinkAndWait(text: string, seconds: number): Yielding { @@ -952,7 +1014,9 @@ export class Sprite extends SpriteBase { speechBubble.timeout = timeout; this._speechBubble = speechBubble; + this._project.requestRedraw(); while (!done) yield; + this._project.requestRedraw(); } public static RotationStyle = Object.freeze({ From c1f741ca75373f3a9e42e922f84929ae487a5655 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 12:40:26 -0400 Subject: [PATCH 09/12] Implement Scratch-like thread sequencing --- src/Project.ts | 62 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 0e4da0d..0f58036 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -27,6 +27,7 @@ export default class Project { private loudnessHandler: LoudnessHandler; private _cachedLoudness: number | null; + private stepTime: number; private threads: Thread[]; private redrawRequested: boolean; @@ -85,9 +86,10 @@ export default class Project { this.answer = null; // Run project code at specified framerate + this.stepTime = 1000 / frameRate; setInterval(() => { this.step(); - }, 1000 / frameRate); + }, this.stepTime); // Render project as fast as possible this._renderLoop(); @@ -188,18 +190,58 @@ export default class Project { this._cachedLoudness = null; this._stepEdgeActivatedTriggers(); - // Step all threads - const threads = this.threads; - for (let i = 0; i < threads.length; i++) { - threads[i].step(); - } + // We can execute code for 75% of the frametime at most. + const WORK_TIME = this.stepTime * 0.75; - // Remove finished threads - this.threads = this.threads.filter( - (thread) => thread.status !== ThreadStatus.DONE - ); + const startTime = Date.now(); + let now = startTime; + let anyThreadsActive = true; this.redrawRequested = false; + + while ( + // There are active threads + this.threads.length > 0 && + anyThreadsActive && + // We have time remaining + now - startTime < WORK_TIME && + // Nothing visual has changed on-screen + !this.redrawRequested + ) { + anyThreadsActive = false; + let anyThreadsStopped = false; + + const threads = this.threads; + for (let i = 0; i < threads.length; i++) { + const thread = threads[i]; + if (thread.status === ThreadStatus.RUNNING) { + thread.step(); + } + + if (thread.status === ThreadStatus.RUNNING) { + anyThreadsActive = true; + } else if (thread.status === ThreadStatus.DONE) { + anyThreadsStopped = true; + } + } + + // Remove finished threads in-place. + if (anyThreadsStopped) { + let nextActiveThreadIndex = 0; + for (let i = 0; i < threads.length; i++) { + const thread = threads[i]; + if (threads[i].status !== ThreadStatus.DONE) { + threads[nextActiveThreadIndex] = thread; + nextActiveThreadIndex++; + } + } + threads.length = nextActiveThreadIndex; + } + + // We set "now" to startTime at first to ensure we iterate through at + // least once in the event of a freak lag spike. + now = Date.now(); + } } private render(): void { From f6acc90f91d76daa70e0303f737ca1e56b079c34 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 12:45:58 -0400 Subject: [PATCH 10/12] Reset speech bubble on green flag --- src/Project.ts | 3 +-- src/Sprite.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 0f58036..a1a4c4b 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -273,8 +273,7 @@ export default class Project { this.filterSprites((sprite) => { if (!sprite.isOriginal) return false; - sprite.effects.clear(); - sprite.audioEffects.clear(); + sprite.reset(); return true; }); } diff --git a/src/Sprite.ts b/src/Sprite.ts index 091a904..7af67c5 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -168,6 +168,11 @@ abstract class SpriteBase { this._initialLayerOrder = null; } + public reset(): void { + this.effects.clear(); + this.audioEffects.clear(); + } + protected getSoundsPlayedByMe(): Sound[] { return this.sounds.filter((sound) => this.effectChain.isTargetOf(sound)); } @@ -615,6 +620,11 @@ export class Sprite extends SpriteBase { return this.original === this; } + public reset(): void { + super.reset(); + this._speechBubble = undefined; + } + public *askAndWait(question: string): Yielding { if (this._speechBubble) { this.say(""); From da8c864f1d702dfbf9eef41f4b5a16b0c3ca62b6 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Tue, 16 Jul 2024 12:48:31 -0400 Subject: [PATCH 11/12] Clean up speech bubble timeout code We can just wait for the timeout by yielding, then clear the speech bubble ourselves. This means we don't have to worry about the timeout affecting things if the script has been stopped or restarted. --- src/Sprite.ts | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/Sprite.ts b/src/Sprite.ts index 7af67c5..259739c 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -101,7 +101,6 @@ export type SpeechBubbleStyle = "say" | "think"; export type SpeechBubble = { text: string; style: SpeechBubbleStyle; - timeout: number | null; }; type InitialConditions = { @@ -612,7 +611,6 @@ export class Sprite extends SpriteBase { this._speechBubble = { text: "", style: "say", - timeout: null, }; } @@ -648,7 +646,6 @@ export class Sprite extends SpriteBase { clone._speechBubble = { text: "", style: "say", - timeout: null, }; clone.effects = this.effects._clone(clone); @@ -982,50 +979,40 @@ export class Sprite extends SpriteBase { } public say(text: string): void { - if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); - this._speechBubble = { text: String(text), style: "say", timeout: null }; + this._speechBubble = { text: String(text), style: "say" }; this._project.requestRedraw(); } public think(text: string): void { - if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); - this._speechBubble = { text: String(text), style: "think", timeout: null }; + this._speechBubble = { text: String(text), style: "think" }; this._project.requestRedraw(); } public *sayAndWait(text: string, seconds: number): Yielding { - if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); - - const speechBubble: SpeechBubble = { text, style: "say", timeout: null }; + const speechBubble: SpeechBubble = { text, style: "say" }; let done = false; - const timeout = window.setTimeout(() => { - speechBubble.text = ""; - speechBubble.timeout = null; + window.setTimeout(() => { done = true; }, seconds * 1000); - speechBubble.timeout = timeout; this._speechBubble = speechBubble; this._project.requestRedraw(); while (!done) yield; + speechBubble.text = ""; this._project.requestRedraw(); } public *thinkAndWait(text: string, seconds: number): Yielding { - if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); - - const speechBubble: SpeechBubble = { text, style: "think", timeout: null }; + const speechBubble: SpeechBubble = { text, style: "think" }; let done = false; - const timeout = window.setTimeout(() => { - speechBubble.text = ""; - speechBubble.timeout = null; + window.setTimeout(() => { done = true; }, seconds * 1000); - speechBubble.timeout = timeout; this._speechBubble = speechBubble; this._project.requestRedraw(); while (!done) yield; + speechBubble.text = ""; this._project.requestRedraw(); } From 9726ebf74ea6884619396c033666cd97ef7eb397 Mon Sep 17 00:00:00 2001 From: adroitwhiz Date: Wed, 17 Jul 2024 08:50:58 -0400 Subject: [PATCH 12/12] Implement primitive support for promises Instead of yielding in a loop until a promise resolves, we "park" the thread. This should help avoid some busy-waits, especially if we ever implement turbo mode. --- src/Project.ts | 47 +++++++---- src/Sound.ts | 48 +++++------ src/Sprite.ts | 46 ++++------- src/Thread.ts | 196 +++++++++++++++++++++++++++++++++++++++++--- src/lib/yielding.ts | 4 +- 5 files changed, 263 insertions(+), 78 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index a1a4c4b..ea5aa15 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -28,6 +28,12 @@ export default class Project { private _cachedLoudness: number | null; private stepTime: number; + /** + * Actively-running scripts. Take care when removing threads--you must always + * set their status to ThreadStatus.DONE before doing so, as other threads may + * be waiting for them to complete. The {@link filterThreads} method does this + * for you. + */ private threads: Thread[]; private redrawRequested: boolean; @@ -214,9 +220,7 @@ export default class Project { const threads = this.threads; for (let i = 0; i < threads.length; i++) { const thread = threads[i]; - if (thread.status === ThreadStatus.RUNNING) { - thread.step(); - } + thread.step(); if (thread.status === ThreadStatus.RUNNING) { anyThreadsActive = true; @@ -227,23 +231,38 @@ export default class Project { // Remove finished threads in-place. if (anyThreadsStopped) { - let nextActiveThreadIndex = 0; - for (let i = 0; i < threads.length; i++) { - const thread = threads[i]; - if (threads[i].status !== ThreadStatus.DONE) { - threads[nextActiveThreadIndex] = thread; - nextActiveThreadIndex++; - } - } - threads.length = nextActiveThreadIndex; + this.filterThreads((thread) => thread.status !== ThreadStatus.DONE); } // We set "now" to startTime at first to ensure we iterate through at - // least once in the event of a freak lag spike. + // least once, no matter how much time occurs between setting startTime + // and the beginning of the loop. now = Date.now(); } } + /** + * Filter out threads (running scripts) by a given predicate, properly + * handling thread cleanup while doing so. + * @param predicate The function used to filter threads. If it returns true, + * the thread will be kept. If it returns false, the thread will be removed. + */ + private filterThreads(predicate: (thread: Thread) => boolean): void { + let nextActiveThreadIndex = 0; + for (let i = 0; i < this.threads.length; i++) { + const thread = this.threads[i]; + if (predicate(thread)) { + this.threads[nextActiveThreadIndex] = thread; + nextActiveThreadIndex++; + } else { + // Set the status to DONE to wake up any threads that may be waiting for + // this one to finish running. + thread.setStatus(ThreadStatus.DONE); + } + } + this.threads.length = nextActiveThreadIndex; + } + private render(): void { // Render to canvas this.renderer.update(); @@ -339,7 +358,7 @@ export default class Project { } private cleanupSprite(sprite: Sprite): void { - this.threads = this.threads.filter(({ target }) => target !== sprite); + this.filterThreads((thread) => thread.target !== sprite); } public changeSpriteLayer( diff --git a/src/Sound.ts b/src/Sound.ts index 74f4142..f0398bd 100644 --- a/src/Sound.ts +++ b/src/Sound.ts @@ -1,5 +1,6 @@ import decodeADPCMAudio, { isADPCMData } from "./lib/decode-adpcm-audio"; import type { Yielding } from "./lib/yielding"; +import Thread from "./Thread"; export default class Sound { public name: string; @@ -84,8 +85,6 @@ export default class Sound { } public *playUntilDone(): Yielding { - let playing = true; - const isLatestCallToStart = yield* this.start(); // If we failed to download the audio buffer, just stop here - the sound will @@ -94,28 +93,31 @@ export default class Sound { return; } - this.source.addEventListener("ended", () => { - playing = false; - delete this._markDone; - }); - - // If there was another call to start() since ours, don't wait for the - // sound to finish before returning. - if (!isLatestCallToStart) { - return; - } - - // Set _markDone after calling start(), because start() will call the existing - // value of _markDone if it's already set. It does this because playUntilDone() - // is meant to be interrupted if another start() is ran while it's playing. - // Of course, we don't want *this* playUntilDone() to be treated as though it - // were interrupted when we call start(), so setting _markDone comes after. - this._markDone = (): void => { - playing = false; - delete this._markDone; - }; + yield* Thread.await( + new Promise((resolve) => { + this.source!.addEventListener("ended", () => { + resolve(); + delete this._markDone; + }); + + // If there was another call to start() since ours, don't wait for the + // sound to finish before returning. + if (!isLatestCallToStart) { + return; + } - while (playing) yield; + // Set _markDone after calling start(), because start() will call the + // existing value of _markDone if it's already set. It does this because + // playUntilDone() is meant to be interrupted if another start() is ran + // while it's playing. Of course, we don't want *this* playUntilDone() + // to be treated as though it were interrupted when we call start(), so + // setting _markDone comes after. + this._markDone = (): void => { + resolve(); + delete this._markDone; + }; + }) + ); } public stop(): void { diff --git a/src/Sprite.ts b/src/Sprite.ts index 259739c..9a6beed 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -4,7 +4,7 @@ import Sound, { EffectChain, AudioEffectMap } from "./Sound"; import Costume from "./Costume"; import type { Mouse } from "./Input"; import type Project from "./Project"; -import Thread, { ThreadStatus } from "./Thread"; +import Thread from "./Thread"; import type Watcher from "./Watcher"; import type { Yielding } from "./lib/yielding"; @@ -345,6 +345,8 @@ abstract class SpriteBase { const endTime = new Date(); endTime.setMilliseconds(endTime.getMilliseconds() + secs * 1000); this._project.requestRedraw(); + // "wait (0) seconds" should yield at least once. + yield; while (new Date() < endTime) { yield; } @@ -401,19 +403,8 @@ abstract class SpriteBase { } } - public *waitForThreads(threads: Thread[]): Yielding { - while (true) { - for (const thread of threads) { - if (thread.status !== ThreadStatus.DONE) { - yield; - } - } - break; - } - } - public broadcast(name: string): Yielding { - return this.waitForThreads( + return Thread.waitForThreads( this._project.fireTrigger(Trigger.BROADCAST, { name }) ); } @@ -428,12 +419,7 @@ abstract class SpriteBase { } public *askAndWait(question: string): Yielding { - let done = false; - void this._project.askAndWait(question).then(() => { - done = true; - }); - - while (!done) yield; + yield* Thread.await(this._project.askAndWait(question)); } public get answer(): string | null { @@ -990,28 +976,28 @@ export class Sprite extends SpriteBase { public *sayAndWait(text: string, seconds: number): Yielding { const speechBubble: SpeechBubble = { text, style: "say" }; - let done = false; - window.setTimeout(() => { - done = true; - }, seconds * 1000); + + const timer = new Promise((resolve) => { + window.setTimeout(resolve, seconds * 1000); + }); this._speechBubble = speechBubble; this._project.requestRedraw(); - while (!done) yield; + yield* Thread.await(timer); speechBubble.text = ""; this._project.requestRedraw(); } public *thinkAndWait(text: string, seconds: number): Yielding { const speechBubble: SpeechBubble = { text, style: "think" }; - let done = false; - window.setTimeout(() => { - done = true; - }, seconds * 1000); + + const timer = new Promise((resolve) => { + window.setTimeout(resolve, seconds * 1000); + }); this._speechBubble = speechBubble; this._project.requestRedraw(); - while (!done) yield; + yield* Thread.await(timer); speechBubble.text = ""; this._project.requestRedraw(); } @@ -1074,7 +1060,7 @@ export class Stage extends SpriteBase { } public fireBackdropChanged(): Yielding { - return this.waitForThreads( + return Thread.waitForThreads( this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { backdrop: this.costume.name, }) diff --git a/src/Thread.ts b/src/Thread.ts index e9b5b91..f486a16 100644 --- a/src/Thread.ts +++ b/src/Thread.ts @@ -1,39 +1,215 @@ +import { Yielding } from "./lib/yielding"; import { Sprite, Stage } from "./Sprite"; import Trigger from "./Trigger"; export enum ThreadStatus { /** This script is currently running. */ RUNNING, - /** - * This script is waiting for a promise, or waiting for other scripts. - * @todo This requires runtime support. - */ - // PARKED, + /** This script is waiting for a promise, or waiting for other scripts. */ + PARKED, /** This script is finished running. */ DONE, } +const YIELD_TO = Symbol("YIELD_TO"); +const PROMISE_WAIT = Symbol("PROMISE_WAIT"); + +/** + * Yielding these special values from a thread will pause the thread's execution + * until some conditions are met. + */ +export type ThreadEffect = + | { + type: typeof YIELD_TO; + thread: Thread; + } + | { + type: typeof PROMISE_WAIT; + promise: Promise; + }; + +const isThreadEffect = (value: unknown): value is ThreadEffect => + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "symbol"; + +enum CompletionKind { + FULFILLED, + REJECTED, +} + export default class Thread { + /** The sprite or stage that this thread's script is part of. */ public target: Sprite | Stage; + /** The trigger that started this thread. Used to restart it. */ public trigger: Trigger; - public status: ThreadStatus; + /** This thread's status. Exposed as a getter and setStatus function. */ + private _status: ThreadStatus; + /** The generator function that's currently executing. */ private runningScript: Generator; + /** + * If this thread was waiting for a promise, the resolved value of that + * promise. It will be passed into the generator function (or thrown, if it's + * an error). + */ + private resolvedValue: { type: CompletionKind; value: unknown } | null; + /** + * Callback functions to execute once this thread exits the "parked" status. + */ + private onUnpark: (() => void)[]; + /** + * Incremented when this thread is restarted; used when resuming from + * promises. If the generation counter is different from what it was when the + * promise started, we don't do anything with the resolved value. + */ + private generation: number; public constructor(trigger: Trigger, target: Sprite | Stage) { this.runningScript = trigger.startScript(target); this.trigger = trigger; this.target = target; - this.status = ThreadStatus.RUNNING; + this._status = ThreadStatus.RUNNING; + this.resolvedValue = null; + this.generation = 0; + this.onUnpark = []; + } + + private unpark(): void { + for (const callback of this.onUnpark) { + callback(); + } + this.onUnpark.length = 0; + } + + public get status(): ThreadStatus { + return this._status; } + /** + * Set the thread's status. This is a function and not a setter to make it + * clearer that it has side effects (potentially calling "on unpark" + * callbacks). + * @param newStatus The status to set. + */ + public setStatus(newStatus: ThreadStatus): void { + if ( + this._status === ThreadStatus.PARKED && + newStatus !== ThreadStatus.PARKED + ) { + this.unpark(); + } + this._status = newStatus; + } + + /** + * Step this thread once. Does nothing if the status is not RUNNING (e.g. the + * thread is waiting for a promise to resolve). + */ public step(): void { - if (this.runningScript.next().done) { - this.status = ThreadStatus.DONE; + if (this._status !== ThreadStatus.RUNNING) return; + + let next; + + // Pass a promise's resolved value into the generator depending on whether + // it fulfilled or rejected. + if (this.resolvedValue !== null) { + if (this.resolvedValue.type === CompletionKind.REJECTED) { + // If the promise rejected, throw the error inside the generator. + next = this.runningScript.throw(this.resolvedValue.value); + } else { + next = this.runningScript.next(this.resolvedValue.value); + } + this.resolvedValue = null; + } else { + next = this.runningScript.next(); } + + if (next.done) { + this.setStatus(ThreadStatus.DONE); + } else if (isThreadEffect(next.value)) { + switch (next.value.type) { + case PROMISE_WAIT: { + // Wait for the promise to resolve then pass its value back into the generator. + this.setStatus(ThreadStatus.PARKED); + const generation = this.generation; + next.value.promise.then( + (value) => { + // If the thread has been restarted since the promise was created, + // do nothing. + if (this.generation !== generation) return; + this.resolvedValue = { type: CompletionKind.FULFILLED, value }; + this.setStatus(ThreadStatus.RUNNING); + }, + (err) => { + if (this.generation !== generation) return; + this.resolvedValue = { + type: CompletionKind.REJECTED, + value: err, + }; + this.setStatus(ThreadStatus.RUNNING); + } + ); + break; + } + case YIELD_TO: { + // If the given thread is parked, park ourselves and wait for it to unpark. + if (next.value.thread.status === ThreadStatus.PARKED) { + this.setStatus(ThreadStatus.PARKED); + next.value.thread.onUnpark.push(() => { + this.setStatus(ThreadStatus.RUNNING); + this.unpark(); + }); + } + } + } + } + this.resolvedValue = null; + } + + /** + * Await a promise and pass the result back into the generator. + * @param promise The promise to await. + * @returns Generator which yields the resolved value. + */ + public static *await(promise: Promise): Generator { + return yield { type: PROMISE_WAIT, promise }; + } + + /** + * If run inside another thread, waits for *this* thread to make progress. + */ + public *yieldTo(): Yielding { + yield { type: YIELD_TO, thread: this }; } + /** + * If run inside another thread, waits until *this* thread is done running. + */ + public *waitUntilDone(): Yielding { + while (this.status !== ThreadStatus.DONE) { + yield* this.yieldTo(); + } + } + + /** + * Wait for all the given threads to finish executing. + * @param threads The threads to wait for. + */ + public static *waitForThreads(threads: Thread[]): Yielding { + for (const thread of threads) { + if (thread.status !== ThreadStatus.DONE) { + yield* thread.waitUntilDone(); + } + } + } + + /** + * Restart this thread in-place. + */ public restart(): void { + this.generation++; this.runningScript = this.trigger.startScript(this.target); - this.status = ThreadStatus.RUNNING; + this.unpark(); } } diff --git a/src/lib/yielding.ts b/src/lib/yielding.ts index 317eb8b..6faf445 100644 --- a/src/lib/yielding.ts +++ b/src/lib/yielding.ts @@ -1,7 +1,9 @@ +import { ThreadEffect } from "../Thread"; + /** * Utility type for a generator function that yields nothing until eventually * resolving to a value. Used extensively in Leopard and defined here so we * don't have to type out the full definition each time (and also so I don't * have to go back and change it everywhere if this type turns out to be wrong). */ -export type Yielding = Generator; +export type Yielding = Generator;