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/Project.ts b/src/Project.ts index 0d1e3e7..ea5aa15 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -3,7 +3,8 @@ 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"; +import Thread, { ThreadStatus } from "./Thread"; type TriggerWithTarget = { target: Sprite | Stage; @@ -13,13 +14,28 @@ 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; private loudnessHandler: LoudnessHandler; private _cachedLoudness: number | null; - public runningTriggers: TriggerWithTarget[]; + 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; public answer: string | null; private timerStart!: Date; @@ -34,23 +50,41 @@ 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.setProject(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) => { - void this.fireTrigger(Trigger.KEY_PRESSED, { key }); + this.fireTrigger(Trigger.KEY_PRESSED, { key }); }); this.loudnessHandler = new LoudnessHandler(); // Only update loudness once per step. this._cachedLoudness = null; - this.runningTriggers = []; + this.threads = []; + this.redrawRequested = false; this._prevStepTriggerPredicates = new WeakMap(); this.restartTimer(); @@ -58,9 +92,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(); @@ -77,7 +112,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, }); @@ -85,14 +120,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 }); } } - void this._startTriggers(matchingTriggers); + this._startThreads(matchingTriggers); }); } @@ -112,9 +147,10 @@ export default class Project { private _matchingTriggers( triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean ): TriggerWithTarget[] { - const matchingTriggers: TriggerWithTarget[] = []; - const targets = this.spritesAndStage; - for (const target of targets) { + 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]; const matchingTargetTriggers = target.triggers.filter((tr) => triggerMatches(tr, target) ); @@ -133,10 +169,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)}`); @@ -149,26 +186,81 @@ 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(); + // We can execute code for 75% of the frametime at most. + const WORK_TIME = this.stepTime * 0.75; + + 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]; + thread.step(); + + if (thread.status === ThreadStatus.RUNNING) { + anyThreadsActive = true; + } else if (thread.status === ThreadStatus.DONE) { + anyThreadsStopped = true; + } + } + + // Remove finished threads in-place. + if (anyThreadsStopped) { + this.filterThreads((thread) => thread.status !== ThreadStatus.DONE); + } + + // We set "now" to startTime at first to ensure we iterate through at + // least once, no matter how much time occurs between setting startTime + // and the beginning of the loop. + now = Date.now(); } + } - // Remove finished triggers - this.runningTriggers = this.runningTriggers.filter( - ({ trigger }) => !trigger.done - ); + /** + * 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 { @@ -190,60 +282,83 @@ export default class Project { this.render(); } - public fireTrigger(trigger: symbol, options?: TriggerOptions): Promise { + public fireTrigger(trigger: symbol, options?: TriggerOptions): Thread[] { // Special trigger behaviors if (trigger === Trigger.GREEN_FLAG) { this.restartTimer(); this.stopAllSounds(); - this.runningTriggers = []; + this.threads = []; - 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(); - } + sprite.reset(); + return true; + }); } const matchingTriggers = this._matchingTriggers((tr, target) => tr.matches(trigger, options, target) ); - return this._startTriggers(matchingTriggers); + return this._startThreads(matchingTriggers); } // TODO: add a way to start clone triggers from fireTrigger then make this private - public async _startTriggers(triggers: TriggerWithTarget[]): Promise { - // 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); } } - await Promise.all( - triggers.map(({ trigger, target }) => trigger.start(target)) - ); + return startedThreads; } - 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 get spritesAndStage(): (Sprite | Stage)[] { - return [...this.spritesAndClones, this.stage]; + 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; + } + + private cleanupSprite(sprite: Sprite): void { + this.filterThreads((thread) => thread.target !== sprite); } public changeSpriteLayer( @@ -251,7 +366,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); @@ -263,17 +378,20 @@ 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 requestRedraw(): void { + this.redrawRequested = true; } 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 7b116db..247d7ce 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. @@ -305,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/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 9ae6392..9a6beed 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -4,6 +4,7 @@ import Sound, { EffectChain, AudioEffectMap } from "./Sound"; import Costume from "./Costume"; import type { Mouse } from "./Input"; import type Project from "./Project"; +import Thread from "./Thread"; import type Watcher from "./Watcher"; import type { Yielding } from "./lib/yielding"; @@ -19,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; @@ -28,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, @@ -58,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)[]) { @@ -80,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(); + } } } @@ -88,7 +101,6 @@ export type SpeechBubbleStyle = "say" | "think"; export type SpeechBubble = { text: string; style: SpeechBubbleStyle; - timeout: number | null; }; type InitialConditions = { @@ -97,10 +109,12 @@ type InitialConditions = { }; abstract class SpriteBase { + // TODO: make this protected and pass it in via the constructor protected _project!: Project; protected _costumeNumber: number; - protected _layerOrder: number; + // TODO: remove this and just store the sprites in layer order, as Scratch does. + private _initialLayerOrder: number | null; public triggers: Trigger[]; public watchers: Partial>; protected costumes: Costume[]; @@ -116,7 +130,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 = {}; @@ -128,12 +142,36 @@ 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) { + throw new Error( + "getInitialLayerOrder should only be called once, when the project is initialized" + ); + } + return order; + } + + public clearInitialLayerOrder(): void { + this._initialLayerOrder = null; + } + + public reset(): void { + this.effects.clear(); + this.audioEffects.clear(); + } + protected getSoundsPlayedByMe(): Sound[] { return this.sounds.filter((sound) => this.effectChain.isTargetOf(sound)); } @@ -160,6 +198,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) { @@ -302,6 +344,9 @@ abstract class SpriteBase { public *wait(secs: number): Yielding { 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; } @@ -358,32 +403,23 @@ abstract class SpriteBase { } } - public broadcast(name: string): Promise { - return this._project.fireTrigger(Trigger.BROADCAST, { name }); + public broadcast(name: string): Yielding { + return Thread.waitForThreads( + this._project.fireTrigger(Trigger.BROADCAST, { name }) + ); } public *broadcastAndWait(name: string): Yielding { - let running = true; - void this.broadcast(name).then(() => { - running = false; - }); - - while (running) { - yield; - } + yield* this.broadcast(name); } public clearPen(): void { this._project.renderer.clearPen(); + this._project.requestRedraw(); } 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 { @@ -517,12 +553,11 @@ 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 parent: this | null; - public clones: this[]; + private original: this; private _penDown: boolean; public penSize: number; @@ -548,13 +583,12 @@ 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.parent = null; - this.clones = []; + this.original = this; this._penDown = penDown || false; this.penSize = penSize || 1; @@ -563,10 +597,18 @@ export class Sprite extends SpriteBase { this._speechBubble = { text: "", style: "say", - timeout: null, }; } + public get isOriginal(): boolean { + return this.original === this; + } + + public reset(): void { + super.reset(); + this._speechBubble = undefined; + } + public *askAndWait(question: string): Yielding { if (this._speechBubble) { this.say(""); @@ -590,50 +632,37 @@ export class Sprite extends SpriteBase { clone._speechBubble = { text: "", style: "say", - 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. - // 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) => tr.matches(Trigger.CLONE_START, {}, clone) ); - void this._project._startTriggers( + void this._project._startThreads( triggers.map((trigger) => ({ trigger, target: clone })) ); } 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); + if (this._visible) this._project.requestRedraw(); } public get direction(): number { @@ -642,6 +671,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 { @@ -658,6 +715,10 @@ export class Sprite extends SpriteBase { this._x = x; this._y = y; + + if (this.penDown || this.visible) { + this._project.requestRedraw(); + } } public get x(): number { @@ -701,7 +762,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 +786,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); @@ -778,6 +839,7 @@ export class Sprite extends SpriteBase { ); } this._penDown = penDown; + if (penDown) this._project.requestRedraw(); } public get penColor(): Color { @@ -796,6 +858,7 @@ export class Sprite extends SpriteBase { public stamp(): void { this._project.renderer.stamp(this); + this._project.requestRedraw(); } public touching( @@ -869,7 +932,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); @@ -902,45 +965,41 @@ 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" }; - const speechBubble: SpeechBubble = { text, style: "say", timeout: null }; - let done = false; - const timeout = window.setTimeout(() => { - speechBubble.text = ""; - speechBubble.timeout = null; - done = true; - }, seconds * 1000); + const timer = new Promise((resolve) => { + window.setTimeout(resolve, seconds * 1000); + }); - speechBubble.timeout = timeout; this._speechBubble = speechBubble; - while (!done) yield; + this._project.requestRedraw(); + yield* Thread.await(timer); + 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" }; - const speechBubble: SpeechBubble = { text, style: "think", timeout: null }; - let done = false; - const timeout = window.setTimeout(() => { - speechBubble.text = ""; - speechBubble.timeout = null; - done = true; - }, seconds * 1000); + const timer = new Promise((resolve) => { + window.setTimeout(resolve, seconds * 1000); + }); - speechBubble.timeout = timeout; this._speechBubble = speechBubble; - while (!done) yield; + this._project.requestRedraw(); + yield* Thread.await(timer); + speechBubble.text = ""; + this._project.requestRedraw(); } public static RotationStyle = Object.freeze({ @@ -953,7 +1012,7 @@ export class Sprite extends SpriteBase { BOTTOM: Symbol("BOTTOM"), LEFT: Symbol("LEFT"), RIGHT: Symbol("RIGHT"), - TOP: Symbol("TOP") + TOP: Symbol("TOP"), }); } @@ -971,7 +1030,7 @@ export class Stage extends SpriteBase { right: number; top: number; bottom: number; - } + }; public constructor(initialConditions: StageInitialConditions, vars = {}) { super(initialConditions, vars); @@ -993,17 +1052,19 @@ 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. this.__counter = 0; } - public fireBackdropChanged(): Promise { - return this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { - backdrop: this.costume.name, - }); + public fireBackdropChanged(): Yielding { + return Thread.waitForThreads( + this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { + backdrop: this.costume.name, + }) + ); } public get costumeNumber(): number { @@ -1012,6 +1073,6 @@ export class Stage extends SpriteBase { public set costumeNumber(number) { super.costumeNumber = number; - void this.fireBackdropChanged(); + this.fireBackdropChanged(); } } diff --git a/src/Thread.ts b/src/Thread.ts new file mode 100644 index 0000000..f486a16 --- /dev/null +++ b/src/Thread.ts @@ -0,0 +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. */ + 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; + /** 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.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._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.unpark(); + } +} diff --git a/src/Trigger.ts b/src/Trigger.ts index 98a82d7..123a4e5 100644 --- a/src/Trigger.ts +++ b/src/Trigger.ts @@ -12,9 +12,6 @@ export default class Trigger { public trigger; private options: TriggerOptions; private _script: GeneratorFunction; - private _runningScript: Generator | undefined; - public done: boolean; - private stop: () => void; public constructor( trigger: symbol, @@ -36,10 +33,6 @@ export default class Trigger { this.options = optionsOrScript as TriggerOptions; this._script = script; } - - this.done = false; - // eslint-disable-next-line @typescript-eslint/no-empty-function - this.stop = () => {}; } public get isEdgeActivated(): boolean { @@ -77,24 +70,8 @@ export default class Trigger { return true; } - public start(target: Sprite | Stage): Promise { - this.stop(); - - this.done = false; - 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(); + public startScript(target: Sprite | Stage): Generator { + return this._script.call(target); } public clone(): Trigger { 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; 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; 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();