Skip to content

Commit 0191147

Browse files
committed
Maintain list of targets in layer order
This matches how Scratch behaves, and means we don't have to mess around with _layerOrder anymore.
1 parent 6f8e928 commit 0191147

File tree

3 files changed

+126
-80
lines changed

3 files changed

+126
-80
lines changed

src/Project.ts

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Renderer from "./Renderer";
33
import Input from "./Input";
44
import LoudnessHandler from "./Loudness";
55
import Sound from "./Sound";
6-
import type { Stage, Sprite } from "./Sprite";
6+
import { Stage, Sprite } from "./Sprite";
77

88
type TriggerWithTarget = {
99
target: Sprite | Stage;
@@ -13,6 +13,13 @@ type TriggerWithTarget = {
1313
export default class Project {
1414
public stage: Stage;
1515
public sprites: Partial<Record<string, Sprite>>;
16+
/**
17+
* All rendered targets (the stage, sprites, and clones), in layer order.
18+
* This is kept private so that nobody can improperly modify it. The only way
19+
* to add or remove targets is via the appropriate methods, and iteration can
20+
* be done with {@link forEachTarget}.
21+
*/
22+
private targets: (Sprite | Stage)[];
1623
public renderer: Renderer;
1724
public input: Input;
1825

@@ -34,12 +41,29 @@ export default class Project {
3441
this.stage = stage;
3542
this.sprites = sprites;
3643

37-
Object.freeze(sprites); // Prevent adding/removing sprites while project is running
44+
this.targets = [
45+
stage,
46+
...Object.values(this.sprites as Record<string, Sprite>),
47+
];
48+
this.targets.sort((a, b) => {
49+
// There should only ever be one stage, but it's best to maintain a total
50+
// ordering to avoid weird sorting-algorithm stuff from happening if
51+
// there's more than one
52+
if (a instanceof Stage && !(b instanceof Stage)) {
53+
return -1;
54+
}
55+
if (b instanceof Stage && !(a instanceof Stage)) {
56+
return 1;
57+
}
3858

39-
for (const sprite of this.spritesAndClones) {
40-
sprite._project = this;
59+
return a.getInitialLayerOrder() - b.getInitialLayerOrder();
60+
});
61+
for (const target of this.targets) {
62+
target.clearInitialLayerOrder();
63+
target._project = this;
4164
}
42-
this.stage._project = this;
65+
66+
Object.freeze(sprites); // Prevent adding/removing sprites while project is running
4367

4468
this.renderer = new Renderer(this, null);
4569
this.input = new Input(this.stage, this.renderer.stage, (key) => {
@@ -77,7 +101,7 @@ export default class Project {
77101
void Sound.audioContext.resume();
78102
}
79103

80-
let clickedSprite = this.renderer.pick(this.spritesAndClones, {
104+
let clickedSprite = this.renderer.pick(this.targets, {
81105
x: this.input.mouse.x,
82106
y: this.input.mouse.y,
83107
});
@@ -113,8 +137,7 @@ export default class Project {
113137
triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean
114138
): TriggerWithTarget[] {
115139
const matchingTriggers: TriggerWithTarget[] = [];
116-
const targets = this.spritesAndStage;
117-
for (const target of targets) {
140+
for (const target of this.targets) {
118141
const matchingTargetTriggers = target.triggers.filter((tr) =>
119142
triggerMatches(tr, target)
120143
);
@@ -198,15 +221,13 @@ export default class Project {
198221
this.stopAllSounds();
199222
this.runningTriggers = [];
200223

201-
for (const spriteName in this.sprites) {
202-
const sprite = this.sprites[spriteName]!;
203-
sprite.clones = [];
204-
}
224+
this.filterSprites((sprite) => {
225+
if (!sprite.isOriginal) return false;
205226

206-
for (const sprite of this.spritesAndStage) {
207227
sprite.effects.clear();
208228
sprite.audioEffects.clear();
209-
}
229+
return true;
230+
});
210231
}
211232

212233
const matchingTriggers = this._matchingTriggers((tr, target) =>
@@ -237,22 +258,49 @@ export default class Project {
237258
);
238259
}
239260

240-
public get spritesAndClones(): Sprite[] {
241-
return Object.values(this.sprites)
242-
.flatMap((sprite) => sprite!.andClones())
243-
.sort((a, b) => a._layerOrder - b._layerOrder);
261+
public addSprite(sprite: Sprite, behind?: Sprite): void {
262+
if (behind) {
263+
const currentIndex = this.targets.indexOf(behind);
264+
this.targets.splice(currentIndex, 0, sprite);
265+
} else {
266+
this.targets.push(sprite);
267+
}
268+
}
269+
270+
public removeSprite(sprite: Sprite): void {
271+
const index = this.targets.indexOf(sprite);
272+
if (index === -1) return;
273+
274+
this.targets.splice(index, 1);
275+
this.cleanupSprite(sprite);
276+
}
277+
278+
public filterSprites(predicate: (sprite: Sprite) => boolean): void {
279+
let nextKeptSpriteIndex = 0;
280+
for (let i = 0; i < this.targets.length; i++) {
281+
const target = this.targets[i];
282+
if (target instanceof Stage || predicate(target)) {
283+
this.targets[nextKeptSpriteIndex] = target;
284+
nextKeptSpriteIndex++;
285+
} else {
286+
this.cleanupSprite(target);
287+
}
288+
}
289+
this.targets.length = nextKeptSpriteIndex;
244290
}
245291

246-
public get spritesAndStage(): (Sprite | Stage)[] {
247-
return [...this.spritesAndClones, this.stage];
292+
private cleanupSprite(sprite: Sprite): void {
293+
this.runningTriggers = this.runningTriggers.filter(
294+
({ target }) => target !== sprite
295+
);
248296
}
249297

250298
public changeSpriteLayer(
251299
sprite: Sprite,
252300
layerDelta: number,
253301
relativeToSprite = sprite
254302
): void {
255-
const spritesArray = this.spritesAndClones;
303+
const spritesArray = this.targets;
256304

257305
const originalIndex = spritesArray.indexOf(sprite);
258306
const relativeToIndex = spritesArray.indexOf(relativeToSprite);
@@ -264,17 +312,16 @@ export default class Project {
264312
// Remove sprite from originalIndex and insert at newIndex
265313
spritesArray.splice(originalIndex, 1);
266314
spritesArray.splice(newIndex, 0, sprite);
315+
}
267316

268-
// spritesArray is sorted correctly, but to influence
269-
// the actual order of the sprites we need to update
270-
// each one's _layerOrder property.
271-
spritesArray.forEach((sprite, index) => {
272-
sprite._layerOrder = index + 1;
273-
});
317+
public forEachTarget(callback: (target: Sprite | Stage) => void): void {
318+
for (const target of this.targets) {
319+
callback(target);
320+
}
274321
}
275322

276323
public stopAllSounds(): void {
277-
for (const target of this.spritesAndStage) {
324+
for (const target of this.targets) {
278325
target.stopAllOfMySounds();
279326
}
280327
}

src/Renderer.ts

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -303,37 +303,33 @@ export default class Renderer {
303303
(filter && !filter(layer))
304304
);
305305

306-
// Stage
307-
if (shouldIncludeLayer(this.project.stage)) {
308-
this.renderSprite(this.project.stage, options);
309-
}
306+
this.project.forEachTarget((target) => {
307+
// TODO: just make a `visible` getter for Stage to avoid this rigmarole
308+
const visible = "visible" in target ? target.visible : true;
310309

311-
// Pen layer
312-
if (shouldIncludeLayer(this._penSkin)) {
313-
const penMatrix = Matrix.create();
314-
Matrix.scale(
315-
penMatrix,
316-
penMatrix,
317-
this._penSkin.width,
318-
-this._penSkin.height
319-
);
320-
Matrix.translate(penMatrix, penMatrix, -0.5, -0.5);
310+
if (shouldIncludeLayer(target) && visible) {
311+
this.renderSprite(target, options);
312+
}
321313

322-
this._renderSkin(
323-
this._penSkin,
324-
options.drawMode,
325-
penMatrix,
326-
1 /* scale */
327-
);
328-
}
314+
// Draw the pen layer in front of the stage
315+
if (target instanceof Stage && shouldIncludeLayer(this._penSkin)) {
316+
const penMatrix = Matrix.create();
317+
Matrix.scale(
318+
penMatrix,
319+
penMatrix,
320+
this._penSkin.width,
321+
-this._penSkin.height
322+
);
323+
Matrix.translate(penMatrix, penMatrix, -0.5, -0.5);
329324

330-
// Sprites + clones
331-
for (const sprite of this.project.spritesAndClones) {
332-
// Stage doesn't have "visible" defined, so check if it's strictly false
333-
if (shouldIncludeLayer(sprite) && sprite.visible !== false) {
334-
this.renderSprite(sprite, options);
325+
this._renderSkin(
326+
this._penSkin,
327+
options.drawMode,
328+
penMatrix,
329+
1 /* scale */
330+
);
335331
}
336-
}
332+
});
337333
}
338334

339335
private _updateStageSize(): void {

src/Sprite.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ abstract class SpriteBase {
102102

103103
protected _costumeNumber: number;
104104
// TODO: remove this and just store the sprites in layer order, as Scratch does.
105-
public _layerOrder: number;
105+
private _initialLayerOrder: number | null;
106106
public triggers: Trigger[];
107107
public watchers: Partial<Record<string, Watcher>>;
108108
protected costumes: Costume[];
@@ -118,7 +118,7 @@ abstract class SpriteBase {
118118
// TODO: pass project in here, ideally
119119
const { costumeNumber, layerOrder = 0 } = initialConditions;
120120
this._costumeNumber = costumeNumber;
121-
this._layerOrder = layerOrder;
121+
this._initialLayerOrder = layerOrder;
122122

123123
this.triggers = [];
124124
this.watchers = {};
@@ -136,6 +136,20 @@ abstract class SpriteBase {
136136
this._vars = vars;
137137
}
138138

139+
public getInitialLayerOrder(): number {
140+
const order = this._initialLayerOrder;
141+
if (order === null) {
142+
throw new Error(
143+
"getInitialLayerOrder should only be called once, when the project is initialized"
144+
);
145+
}
146+
return order;
147+
}
148+
149+
public clearInitialLayerOrder(): void {
150+
this._initialLayerOrder = null;
151+
}
152+
139153
protected getSoundsPlayedByMe(): Sound[] {
140154
return this.sounds.filter((sound) => this.effectChain.isTargetOf(sound));
141155
}
@@ -523,8 +537,7 @@ export class Sprite extends SpriteBase {
523537
public size: number;
524538
public visible: boolean;
525539

526-
private parent: this | null;
527-
public clones: this[];
540+
private original: this;
528541

529542
private _penDown: boolean;
530543
public penSize: number;
@@ -555,8 +568,7 @@ export class Sprite extends SpriteBase {
555568
this.size = size;
556569
this.visible = visible;
557570

558-
this.parent = null;
559-
this.clones = [];
571+
this.original = this;
560572

561573
this._penDown = penDown || false;
562574
this.penSize = penSize || 1;
@@ -569,6 +581,10 @@ export class Sprite extends SpriteBase {
569581
};
570582
}
571583

584+
public get isOriginal(): boolean {
585+
return this.original === this;
586+
}
587+
572588
public *askAndWait(question: string): Yielding<void> {
573589
if (this._speechBubble) {
574590
this.say("");
@@ -599,21 +615,16 @@ export class Sprite extends SpriteBase {
599615

600616
// Clones inherit audio effects from the original sprite, for some reason.
601617
// Couldn't explain it, but that's the behavior in Scratch 3.0.
602-
// eslint-disable-next-line @typescript-eslint/no-this-alias
603-
let original = this;
604-
while (original.parent) {
605-
original = original.parent;
606-
}
607-
clone.effectChain = original.effectChain.clone({
618+
clone.effectChain = this.original.effectChain.clone({
608619
getNonPatchSoundList: clone.getSoundsPlayedByMe.bind(clone),
609620
});
610621

611622
// Make a new audioEffects interface which acts on the cloned effect chain.
612623
clone.audioEffects = new AudioEffectMap(clone.effectChain);
613624

614-
clone.clones = [];
615-
clone.parent = this;
616-
this.clones.push(clone);
625+
clone.original = this.original;
626+
627+
this._project.addSprite(clone, this);
617628

618629
// Trigger CLONE_START:
619630
const triggers = clone.triggers.filter((tr) =>
@@ -625,17 +636,9 @@ export class Sprite extends SpriteBase {
625636
}
626637

627638
public deleteThisClone(): void {
628-
if (this.parent === null) return;
629-
630-
this.parent.clones = this.parent.clones.filter((clone) => clone !== this);
631-
632-
this._project.runningTriggers = this._project.runningTriggers.filter(
633-
({ target }) => target !== this
634-
);
635-
}
639+
if (this.isOriginal) return;
636640

637-
public andClones(): this[] {
638-
return [this, ...this.clones.flatMap((clone) => clone.andClones())];
641+
this._project.removeSprite(this);
639642
}
640643

641644
public get direction(): number {

0 commit comments

Comments
 (0)