Repository navigation
Expand file tree
/
Copy pathProject.ts
More file actions
301 lines (252 loc) · 9.01 KB
/
Copy pathProject.ts
File metadata and controls
301 lines (252 loc) · 9.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import Trigger, { TriggerOptions } from "./Trigger";
import Renderer from "./Renderer";
import Input from "./Input";
import LoudnessHandler from "./Loudness";
import Sound from "./Sound";
import type { Stage, Sprite } from "./Sprite";
type TriggerWithTarget = {
target: Sprite | Stage;
trigger: Trigger;
};
export default class Project {
public stage: Stage;
public sprites: Partial<Record<string, Sprite>>;
public renderer: Renderer;
public input: Input;
private loudnessHandler: LoudnessHandler;
private _cachedLoudness: number | null;
public runningTriggers: TriggerWithTarget[];
public answer: string | null;
private timerStart!: Date;
/**
* Used to keep track of what edge-activated trigger predicates evaluted to
* on the previous step.
*/
private _prevStepTriggerPredicates: WeakMap<Trigger, boolean>;
public constructor(stage: Stage, sprites = {}, { frameRate = 30 } = {}) {
this.stage = stage;
this.sprites = sprites;
Object.freeze(sprites); // Prevent adding/removing sprites while project is running
for (const sprite of this.spritesAndClones) {
sprite._project = this;
}
this.stage._project = this;
this.renderer = new Renderer(this, null);
this.input = new Input(this.stage, this.renderer.stage, (key) => {
void this.fireTrigger(Trigger.KEY_PRESSED, { key });
});
this.loudnessHandler = new LoudnessHandler();
// Only update loudness once per step.
this._cachedLoudness = null;
this.runningTriggers = [];
this._prevStepTriggerPredicates = new WeakMap();
this.restartTimer();
this.answer = null;
// Run project code at specified framerate
setInterval(() => {
this.step();
}, 1000 / frameRate);
// Render project as fast as possible
this._renderLoop();
}
public attach(renderTarget: string | HTMLElement): void {
this.renderer.setRenderTarget(renderTarget);
this.renderer.stage.addEventListener("click", () => {
// Chrome requires a user gesture on the page before we can start the
// audio context.
// When we click the stage, that counts as a user gesture, so try
// resuming the audio context.
if (Sound.audioContext.state === "suspended") {
void Sound.audioContext.resume();
}
let clickedSprite = this.renderer.pick(this.spritesAndClones, {
x: this.input.mouse.x,
y: this.input.mouse.y,
});
if (!clickedSprite) {
clickedSprite = this.stage;
}
const matchingTriggers: TriggerWithTarget[] = [];
for (const trigger of clickedSprite.triggers) {
if (trigger.matches(Trigger.CLICKED, {}, clickedSprite)) {
matchingTriggers.push({ trigger, target: clickedSprite });
}
}
void this._startTriggers(matchingTriggers);
});
}
public greenFlag(): void {
// Chrome requires a user gesture on the page before we can start the
// audio context.
// When greenFlag is triggered, it's likely that the cause of it was some
// kind of button click, so try resuming the audio context.
if (Sound.audioContext.state === "suspended") {
void Sound.audioContext.resume();
}
void this.fireTrigger(Trigger.GREEN_FLAG);
this.input.focus();
}
// Find triggers which match the given condition
private _matchingTriggers(
triggerMatches: (tr: Trigger, target: Sprite | Stage) => boolean
): TriggerWithTarget[] {
const matchingTriggers: TriggerWithTarget[] = [];
const targets = this.spritesAndStage;
for (const target of targets) {
const matchingTargetTriggers = target.triggers.filter((tr) =>
triggerMatches(tr, target)
);
for (const match of matchingTargetTriggers) {
matchingTriggers.push({ trigger: match, target });
}
}
return matchingTriggers;
}
private _stepEdgeActivatedTriggers(): void {
const edgeActivated = this._matchingTriggers((tr) => tr.isEdgeActivated);
const triggersToStart: TriggerWithTarget[] = [];
for (const triggerWithTarget of edgeActivated) {
const { trigger, target } = triggerWithTarget;
let predicate;
switch (trigger.trigger) {
case Trigger.TIMER_GREATER_THAN:
predicate = this.timer > (trigger.option("VALUE", target) as number);
break;
case Trigger.LOUDNESS_GREATER_THAN:
predicate =
this.loudness > (trigger.option("VALUE", target) as number);
break;
default:
throw new Error(`Unimplemented trigger ${String(trigger.trigger)}`);
}
// Default to false
const prevPredicate = !!this._prevStepTriggerPredicates.get(trigger);
this._prevStepTriggerPredicates.set(trigger, predicate);
// The predicate evaluated to false last time and true this time
// Activate the trigger
if (!prevPredicate && predicate) {
triggersToStart.push(triggerWithTarget);
}
}
void this._startTriggers(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();
}
// Remove finished triggers
this.runningTriggers = this.runningTriggers.filter(
({ trigger }) => !trigger.done
);
}
private render(): void {
// Render to canvas
this.renderer.update();
// Update watchers
if (this.renderer.renderTarget) {
for (const sprite of [...Object.values(this.sprites), this.stage]) {
for (const watcher of Object.values(sprite!.watchers)) {
watcher!.updateDOM(this.renderer.renderTarget);
}
}
}
}
private _renderLoop(): void {
requestAnimationFrame(this._renderLoop.bind(this));
this.render();
}
public fireTrigger(trigger: symbol, options?: TriggerOptions): Promise<void> {
// Special trigger behaviors
if (trigger === Trigger.GREEN_FLAG) {
this.restartTimer();
this.stopAllSounds();
this.runningTriggers = [];
for (const spriteName in this.sprites) {
const sprite = this.sprites[spriteName]!;
sprite.clones = [];
}
for (const sprite of this.spritesAndStage) {
sprite.effects.clear();
sprite.audioEffects.clear();
}
}
const matchingTriggers = this._matchingTriggers((tr, target) =>
tr.matches(trigger, options, target)
);
return this._startTriggers(matchingTriggers);
}
// TODO: add a way to start clone triggers from fireTrigger then make this private
public async _startTriggers(triggers: TriggerWithTarget[]): Promise<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?
for (const trigger of triggers) {
if (
!this.runningTriggers.find(
(runningTrigger) =>
trigger.trigger === runningTrigger.trigger &&
trigger.target === runningTrigger.target
)
) {
this.runningTriggers.push(trigger);
}
}
await Promise.all(
triggers.map(({ trigger, target }) => trigger.start(target))
);
}
public get spritesAndClones(): Sprite[] {
return Object.values(this.sprites)
.flatMap((sprite) => sprite!.andClones())
.sort((a, b) => a._layerOrder - b._layerOrder);
}
public get spritesAndStage(): (Sprite | Stage)[] {
return [...this.spritesAndClones, this.stage];
}
public changeSpriteLayer(
sprite: Sprite,
layerDelta: number,
relativeToSprite = sprite
): void {
const spritesArray = this.spritesAndClones;
const originalIndex = spritesArray.indexOf(sprite);
const relativeToIndex = spritesArray.indexOf(relativeToSprite);
let newIndex = relativeToIndex + layerDelta;
if (newIndex < 0) newIndex = 0;
if (newIndex > spritesArray.length - 1) newIndex = spritesArray.length - 1;
// 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 stopAllSounds(): void {
for (const target of this.spritesAndStage) {
target.stopAllOfMySounds();
}
}
public get timer(): number {
const ms = new Date().getTime() - this.timerStart.getTime();
return ms / 1000;
}
public restartTimer(): void {
this.timerStart = new Date();
}
public async askAndWait(question: string): Promise<void> {
this.answer = await this.renderer.displayAskBox(question);
}
public get loudness(): number {
if (this._cachedLoudness === null) {
this._cachedLoudness = this.loudnessHandler.getLoudness();
}
return this._cachedLoudness;
}
}