@mar7th/firework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ import { builtInPresets, defaultConfig, sanitizeConfig } from "../config/defaults.js";
2
+ import { EventBus } from "../utils/eventBus.js";
3
+ import { random } from "../utils/math.js";
4
+ import { BackgroundManager } from "../effects/BackgroundManager.js";
5
+ import { FireworkFactory } from "./Firework.js";
6
+ import { ParticleSystem } from "./ParticleSystem.js";
7
+ import { Renderer } from "./Renderer.js";
8
+
9
+ export class FireworksSimulator {
10
+ constructor(options = {}) {
11
+ const canvas = resolveCanvas(options.canvasId ?? options.canvas);
12
+ if (!canvas) {
13
+ throw new Error("FireworksSimulator requires a valid canvas or canvasId.");
14
+ }
15
+
16
+ this.canvas = canvas;
17
+ this.prefersReducedMotion =
18
+ options.prefersReducedMotion ??
19
+ window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ??
20
+ false;
21
+ this.initialConfig = this.createInitialConfig(options.config);
22
+ this.config = { ...this.initialConfig };
23
+ this.eventBus = options.eventBus ?? new EventBus();
24
+ this.onConfigChange = options.onConfigChange ?? (() => {});
25
+ this.onFpsChange = options.onFpsChange ?? (() => {});
26
+ this.onLowFps = options.onLowFps ?? (() => {});
27
+ this.advancedMode = Boolean(options.advancedMode);
28
+ this.createParticles = typeof options.createParticles === "function" ? options.createParticles : null;
29
+
30
+ this.backgroundManager = new BackgroundManager();
31
+ this.renderer = new Renderer(canvas, this.backgroundManager);
32
+ this.fireworkFactory = new FireworkFactory(() => this.config);
33
+ this.particleSystem = new ParticleSystem(this.fireworkFactory);
34
+ this.shells = [];
35
+
36
+ this.paused = false;
37
+ this.destroyed = false;
38
+ this.lastTime = performance.now();
39
+ this.autoTimer = 0;
40
+ this.fpsFrames = 0;
41
+ this.fpsElapsed = 0;
42
+ this.lowFpsTime = 0;
43
+ this.warnedAboutPerformance = false;
44
+
45
+ this.handlePointerDown = this.handlePointerDown.bind(this);
46
+ this.handleKeyDown = this.handleKeyDown.bind(this);
47
+ this.handleResize = this.resize.bind(this);
48
+ this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
49
+ this.animate = this.animate.bind(this);
50
+
51
+ if (options.clickToLaunch !== false) {
52
+ this.canvas.addEventListener("pointerdown", this.handlePointerDown);
53
+ }
54
+ if (options.keyboard !== false) {
55
+ this.canvas.addEventListener("keydown", this.handleKeyDown);
56
+ }
57
+ if (options.autoResize !== false) {
58
+ window.addEventListener("resize", this.handleResize);
59
+ }
60
+ if (options.pauseOnHidden !== false) {
61
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
62
+ }
63
+
64
+ this.resize();
65
+ this.renderer.clearFrame(this.config, true);
66
+
67
+ if (options.initialLaunch !== false) {
68
+ window.setTimeout(() => {
69
+ if (this.destroyed) return;
70
+ this.launch(this.renderer.width * 0.34, this.renderer.height * 0.42);
71
+ this.launch(this.renderer.width * 0.62, this.renderer.height * 0.34);
72
+ }, 300);
73
+ }
74
+
75
+ this.animationId = requestAnimationFrame(this.animate);
76
+ }
77
+
78
+ createInitialConfig(config = {}) {
79
+ const nextConfig = {
80
+ ...defaultConfig,
81
+ ...(this.prefersReducedMotion
82
+ ? {
83
+ particleCount: 70,
84
+ initialSpeedMin: 1.4,
85
+ initialSpeedMax: 4.2,
86
+ autoLaunch: false,
87
+ enableTrails: false
88
+ }
89
+ : {}),
90
+ ...config
91
+ };
92
+ this.normalizeAutoLaunchIntervalPriority(nextConfig, config);
93
+ return sanitizeConfig(nextConfig);
94
+ }
95
+
96
+ getConfig() {
97
+ return { ...this.config };
98
+ }
99
+
100
+ setConfig(partialConfig = {}) {
101
+ const nextConfig = { ...this.config, ...partialConfig };
102
+ this.normalizeAutoLaunchIntervalPriority(nextConfig, partialConfig);
103
+ this.config = sanitizeConfig(nextConfig);
104
+ this.notifyConfigChange();
105
+ this.renderer.clearFrame(this.config, true);
106
+ return this.getConfig();
107
+ }
108
+
109
+ selectPreset(presetId) {
110
+ if (presetId === "default") {
111
+ return this.setConfig(defaultConfig);
112
+ }
113
+
114
+ const normalizedId = presetId?.startsWith?.("builtin:")
115
+ ? presetId.replace("builtin:", "")
116
+ : presetId;
117
+ const preset = builtInPresets[normalizedId];
118
+ if (!preset) {
119
+ throw new Error(`Unknown preset: ${presetId}`);
120
+ }
121
+
122
+ return this.setConfig({ ...defaultConfig, ...preset.config });
123
+ }
124
+
125
+ resetConfig(nextInitialConfig) {
126
+ if (nextInitialConfig) {
127
+ this.initialConfig = this.createInitialConfig(nextInitialConfig);
128
+ }
129
+ this.config = { ...this.initialConfig };
130
+ this.clearParticles();
131
+ this.autoTimer = 0;
132
+ this.warnedAboutPerformance = false;
133
+ this.notifyConfigChange();
134
+ this.renderer.clearFrame(this.config, true);
135
+ return this.getConfig();
136
+ }
137
+
138
+ startAutoPlay(interval) {
139
+ const nextConfig = { autoLaunch: true };
140
+ if (Number.isFinite(Number(interval))) {
141
+ Object.assign(nextConfig, this.normalizeAutoPlayIntervalInput(interval));
142
+ }
143
+ return this.setConfig(nextConfig);
144
+ }
145
+
146
+ stopAutoPlay() {
147
+ return this.setConfig({ autoLaunch: false });
148
+ }
149
+
150
+ setAutoPlay(enabled, interval) {
151
+ return enabled ? this.startAutoPlay(interval) : this.stopAutoPlay();
152
+ }
153
+
154
+ setAdvancedMode(enabled, createParticles) {
155
+ this.advancedMode = Boolean(enabled);
156
+ if (typeof createParticles === "function") {
157
+ this.createParticles = createParticles;
158
+ }
159
+ }
160
+
161
+ setParticleCreator(createParticles) {
162
+ this.createParticles = typeof createParticles === "function" ? createParticles : null;
163
+ }
164
+
165
+ clearParticles() {
166
+ this.particleSystem.clear();
167
+ this.shells.length = 0;
168
+ this.renderer.clearFrame(this.config, true);
169
+ }
170
+
171
+ launch(x, y, options = {}) {
172
+ const withShell = options.withShell ?? this.config.showLaunchTrail;
173
+ this.launchAt(x, y, withShell);
174
+ }
175
+
176
+ launchRandom() {
177
+ const x = random(this.renderer.width * 0.16, this.renderer.width * 0.84);
178
+ const y = random(this.renderer.height * 0.16, this.renderer.height * 0.62);
179
+ this.launchAt(x, y, this.config.showLaunchTrail);
180
+ }
181
+
182
+ pause() {
183
+ this.paused = true;
184
+ }
185
+
186
+ resume() {
187
+ this.paused = false;
188
+ this.lastTime = performance.now();
189
+ }
190
+
191
+ togglePause() {
192
+ if (this.paused) this.resume();
193
+ else this.pause();
194
+ return this.paused;
195
+ }
196
+
197
+ resize() {
198
+ this.renderer.resize();
199
+ this.renderer.clearFrame(this.config, true);
200
+ }
201
+
202
+ setBackgroundImage(file, onDone, onError) {
203
+ this.backgroundManager.setImage(
204
+ file,
205
+ () => {
206
+ this.setConfig({ backgroundType: "image" });
207
+ onDone?.();
208
+ },
209
+ onError
210
+ );
211
+ }
212
+
213
+ destroy() {
214
+ this.destroyed = true;
215
+ cancelAnimationFrame(this.animationId);
216
+ this.canvas.removeEventListener("pointerdown", this.handlePointerDown);
217
+ this.canvas.removeEventListener("keydown", this.handleKeyDown);
218
+ window.removeEventListener("resize", this.handleResize);
219
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
220
+ }
221
+
222
+ on(event, callback) {
223
+ return this.eventBus.on(event, callback);
224
+ }
225
+
226
+ launchAt(x, y, withShell = true) {
227
+ if (withShell && this.config.showLaunchTrail) {
228
+ this.shells.push(this.fireworkFactory.createLaunch(x, this.renderer.height + 16, x, y));
229
+ return;
230
+ }
231
+ this.particleSystem.addExplosion(this.createExplosionParticles(x, y));
232
+ }
233
+
234
+ createExplosionParticles(x, y) {
235
+ if (!this.advancedMode || !this.createParticles) {
236
+ return this.fireworkFactory.createExplosion(x, y);
237
+ }
238
+
239
+ const { particles, scopedConfig } = this.parseAdvancedResult(this.createParticles(this.getCanvasData(x, y)));
240
+ if (!Array.isArray(particles)) {
241
+ return [];
242
+ }
243
+
244
+ return particles.map((particle) => this.normalizeParticle(particle, x, y, scopedConfig));
245
+ }
246
+
247
+ parseAdvancedResult(result) {
248
+ if (Array.isArray(result)) {
249
+ return {
250
+ particles: result,
251
+ scopedConfig: null
252
+ };
253
+ }
254
+
255
+ if (result && typeof result === "object" && Array.isArray(result.particles)) {
256
+ return {
257
+ particles: result.particles,
258
+ scopedConfig: result.config ? sanitizeConfig({ ...this.config, ...result.config }) : null
259
+ };
260
+ }
261
+
262
+ return {
263
+ particles: [],
264
+ scopedConfig: null
265
+ };
266
+ }
267
+
268
+ getCanvasData(x, y) {
269
+ return {
270
+ canvas: this.canvas,
271
+ ctx: this.renderer.ctx,
272
+ x,
273
+ y,
274
+ width: this.renderer.width,
275
+ height: this.renderer.height,
276
+ pixelRatio: this.renderer.pixelRatio,
277
+ config: this.getConfig(),
278
+ random
279
+ };
280
+ }
281
+
282
+ normalizeParticle(particle, x, y, scopedConfig = null) {
283
+ const fallbackConfig = scopedConfig || this.config;
284
+ const secondaryBurstConfig =
285
+ particle.secondaryBurstConfig && typeof particle.secondaryBurstConfig === "object"
286
+ ? this.normalizeSecondaryBurstConfig(particle.secondaryBurstConfig, fallbackConfig)
287
+ : null;
288
+
289
+ return {
290
+ x: Number.isFinite(particle.x) ? particle.x : x,
291
+ y: Number.isFinite(particle.y) ? particle.y : y,
292
+ vx: Number.isFinite(particle.vx) ? particle.vx : 0,
293
+ vy: Number.isFinite(particle.vy) ? particle.vy : 0,
294
+ size: Number.isFinite(particle.size) ? particle.size : fallbackConfig.particleMinSize,
295
+ life: Number.isFinite(particle.life) ? particle.life : fallbackConfig.particleLife,
296
+ startColor: particle.startColor || particle.color || fallbackConfig.colorStart,
297
+ endColor: particle.endColor || particle.color || fallbackConfig.colorEnd,
298
+ textureType: particle.textureType || fallbackConfig.textureType,
299
+ rotationVelocity: Number.isFinite(particle.rotationVelocity)
300
+ ? particle.rotationVelocity
301
+ : random(-fallbackConfig.rotationSpeed, fallbackConfig.rotationSpeed),
302
+ secondaryEligible: Boolean(particle.secondaryEligible),
303
+ secondaryBurstConfig,
304
+ config: scopedConfig
305
+ };
306
+ }
307
+
308
+ normalizeSecondaryBurstConfig(config, fallbackConfig) {
309
+ const sanitized = sanitizeConfig({
310
+ ...fallbackConfig,
311
+ ...config,
312
+ enableSecondaryBurst: false
313
+ });
314
+ const partial = { enableSecondaryBurst: false };
315
+
316
+ for (const key of Object.keys(config)) {
317
+ if (key === "enableSecondaryBurst") continue;
318
+ if (key in sanitized) {
319
+ partial[key] = sanitized[key];
320
+ }
321
+ }
322
+
323
+ return partial;
324
+ }
325
+
326
+ animate(now) {
327
+ if (this.destroyed) return;
328
+
329
+ const deltaMs = Math.min(50, now - this.lastTime);
330
+ this.lastTime = now;
331
+
332
+ if (!this.paused) {
333
+ const step = deltaMs / 16.6667;
334
+ this.renderer.clearFrame(this.config);
335
+
336
+ for (let i = this.shells.length - 1; i >= 0; i -= 1) {
337
+ if (this.shells[i].update(step)) {
338
+ const shell = this.shells.splice(i, 1)[0];
339
+ this.particleSystem.addExplosion(this.createExplosionParticles(shell.targetX, shell.targetY));
340
+ }
341
+ }
342
+
343
+ this.particleSystem.update(step, this.config);
344
+ this.renderer.draw(this.config, this.particleSystem, this.shells);
345
+
346
+ if (this.config.autoLaunch) {
347
+ this.autoTimer += deltaMs;
348
+ if (this.autoTimer >= this.getAutoLaunchIntervalMs()) {
349
+ this.autoTimer = 0;
350
+ this.launchRandom();
351
+ }
352
+ }
353
+
354
+ this.updateFps(deltaMs);
355
+ }
356
+
357
+ this.animationId = requestAnimationFrame(this.animate);
358
+ }
359
+
360
+ updateFps(deltaMs) {
361
+ this.fpsFrames += 1;
362
+ this.fpsElapsed += deltaMs;
363
+ if (this.fpsElapsed < 500) return;
364
+
365
+ const fps = Math.round((this.fpsFrames * 1000) / this.fpsElapsed);
366
+ this.onFpsChange(fps);
367
+ this.eventBus.emit("fps:change", fps);
368
+
369
+ this.lowFpsTime = fps < 45 ? this.lowFpsTime + this.fpsElapsed : 0;
370
+ if (this.lowFpsTime > 2200 && !this.warnedAboutPerformance) {
371
+ this.warnedAboutPerformance = true;
372
+ this.config.particleCount = Math.max(60, Math.round(this.config.particleCount * 0.72));
373
+ this.notifyConfigChange();
374
+ this.onLowFps(this.getConfig());
375
+ this.eventBus.emit("performance:low-fps", this.getConfig());
376
+ }
377
+
378
+ this.fpsFrames = 0;
379
+ this.fpsElapsed = 0;
380
+ }
381
+
382
+ notifyConfigChange() {
383
+ const snapshot = this.getConfig();
384
+ this.onConfigChange(snapshot);
385
+ this.eventBus.emit("simulator:config-change", { config: snapshot });
386
+ }
387
+
388
+ handlePointerDown(event) {
389
+ const rect = this.canvas.getBoundingClientRect();
390
+ this.launch(event.clientX - rect.left, event.clientY - rect.top);
391
+ }
392
+
393
+ handleKeyDown(event) {
394
+ if (event.code === "Space") {
395
+ event.preventDefault();
396
+ this.togglePause();
397
+ } else if (event.code === "Enter") {
398
+ event.preventDefault();
399
+ this.launchRandom();
400
+ }
401
+ }
402
+
403
+ handleVisibilityChange() {
404
+ if (document.hidden) {
405
+ this.pause();
406
+ } else {
407
+ this.lastTime = performance.now();
408
+ }
409
+ }
410
+
411
+ getAutoLaunchIntervalMs() {
412
+ const intervalSeconds = Number(this.config.autoLaunchIntervalSeconds);
413
+ if (Number.isFinite(intervalSeconds)) {
414
+ return intervalSeconds * 1000;
415
+ }
416
+ return this.config.autoInterval;
417
+ }
418
+
419
+ normalizeAutoPlayIntervalInput(interval) {
420
+ const value = Number(interval);
421
+ return value > 10 ? { autoInterval: value } : { autoLaunchIntervalSeconds: value };
422
+ }
423
+
424
+ normalizeAutoLaunchIntervalPriority(nextConfig, sourceConfig) {
425
+ if ("autoInterval" in sourceConfig && !("autoLaunchIntervalSeconds" in sourceConfig)) {
426
+ delete nextConfig.autoLaunchIntervalSeconds;
427
+ }
428
+ if ("autoLaunchIntervalSeconds" in sourceConfig && !("autoInterval" in sourceConfig)) {
429
+ delete nextConfig.autoInterval;
430
+ }
431
+ }
432
+ }
433
+
434
+ function resolveCanvas(canvasOrId) {
435
+ if (typeof canvasOrId === "string") {
436
+ return document.getElementById(canvasOrId);
437
+ }
438
+ return canvasOrId;
439
+ }
@@ -0,0 +1,174 @@
1
+ import { clamp, lerp, random } from "../utils/math.js";
2
+ import { mixColor, rgbToCss } from "../utils/color.js";
3
+
4
+ export class Particle {
5
+ active = false;
6
+
7
+ reset(options) {
8
+ this.active = true;
9
+ this.x = options.x;
10
+ this.y = options.y;
11
+ this.vx = options.vx;
12
+ this.vy = options.vy;
13
+ this.size = options.size;
14
+ this.maxLife = options.life;
15
+ this.life = options.life;
16
+ this.startColor = options.startColor;
17
+ this.endColor = options.endColor;
18
+ this.textureType = options.textureType;
19
+ this.config = options.config || null;
20
+ this.rotation = random(0, Math.PI * 2);
21
+ this.rotationVelocity = options.rotationVelocity;
22
+ this.secondaryEligible = options.secondaryEligible;
23
+ this.secondaryBurstConfig = options.secondaryBurstConfig || null;
24
+ this.secondaryTriggered = false;
25
+ this.flickerSeed = random(0, 1000);
26
+ this.trail = [];
27
+ }
28
+
29
+ update(step, config) {
30
+ if (!this.active) return null;
31
+ const runtimeConfig = this.config || config;
32
+
33
+ const windX = runtimeConfig.windEnabled ? runtimeConfig.windX : 0;
34
+ const windY = runtimeConfig.windEnabled ? runtimeConfig.windY : 0;
35
+
36
+ this.vx += windX * step;
37
+ this.vy += (runtimeConfig.gravity + windY) * step;
38
+ this.vx *= Math.pow(runtimeConfig.damping, step);
39
+ this.vy *= Math.pow(runtimeConfig.damping, step);
40
+
41
+ this.x += this.vx * step;
42
+ this.y += this.vy * step;
43
+ this.rotation += this.rotationVelocity * step;
44
+ this.life -= step;
45
+
46
+ if (runtimeConfig.enableTrails) {
47
+ this.trail.push({ x: this.x, y: this.y, life: this.life });
48
+ if (this.trail.length > 8) this.trail.shift();
49
+ } else {
50
+ this.trail.length = 0;
51
+ }
52
+
53
+ if (
54
+ runtimeConfig.enableSecondaryBurst &&
55
+ this.secondaryEligible &&
56
+ !this.secondaryTriggered &&
57
+ this.life < this.maxLife * 0.52
58
+ ) {
59
+ this.secondaryTriggered = true;
60
+ this.active = false;
61
+ return {
62
+ x: this.x,
63
+ y: this.y,
64
+ source: {
65
+ startColor: this.startColor,
66
+ endColor: this.endColor,
67
+ textureType: this.textureType,
68
+ size: this.size,
69
+ maxLife: this.maxLife,
70
+ config: runtimeConfig,
71
+ secondaryBurstConfig: this.secondaryBurstConfig
72
+ }
73
+ };
74
+ }
75
+
76
+ if (this.life <= 0) {
77
+ this.active = false;
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ draw(ctx, config) {
84
+ if (!this.active) return;
85
+ const runtimeConfig = this.config || config;
86
+
87
+ const lifeRatio = clamp(this.life / this.maxLife, 0, 1);
88
+ const age = 1 - lifeRatio;
89
+ const size = lerp(this.size * runtimeConfig.sizeEndMultiplier, this.size, lifeRatio);
90
+ const colorT = clamp(age + runtimeConfig.colorShiftRate * age * 8, 0, 1);
91
+ const color = mixColor(this.startColor, this.endColor, colorT);
92
+ let alpha = lifeRatio;
93
+
94
+ if (runtimeConfig.flicker) {
95
+ alpha *= 0.72 + Math.abs(Math.sin(this.flickerSeed + this.life * 0.34)) * 0.34;
96
+ }
97
+
98
+ if (runtimeConfig.enableTrails && this.trail.length > 1) {
99
+ this.drawTrail(ctx, color, alpha, size);
100
+ }
101
+
102
+ ctx.save();
103
+ ctx.translate(this.x, this.y);
104
+ ctx.rotate(this.rotation);
105
+ ctx.fillStyle = rgbToCss(color, alpha);
106
+ ctx.shadowColor = rgbToCss(color, alpha);
107
+ ctx.shadowBlur = 18 * runtimeConfig.glowIntensity;
108
+ drawTexture(ctx, this.textureType || runtimeConfig.textureType, size);
109
+ ctx.restore();
110
+ }
111
+
112
+ drawTrail(ctx, color, alpha, size) {
113
+ ctx.save();
114
+ ctx.lineCap = "round";
115
+ ctx.lineJoin = "round";
116
+
117
+ for (let i = 1; i < this.trail.length; i += 1) {
118
+ const previous = this.trail[i - 1];
119
+ const current = this.trail[i];
120
+ const t = i / this.trail.length;
121
+ ctx.strokeStyle = rgbToCss(color, alpha * t * 0.26);
122
+ ctx.lineWidth = Math.max(1, size * t * 0.8);
123
+ ctx.beginPath();
124
+ ctx.moveTo(previous.x, previous.y);
125
+ ctx.lineTo(current.x, current.y);
126
+ ctx.stroke();
127
+ }
128
+
129
+ ctx.restore();
130
+ }
131
+ }
132
+
133
+ function drawTexture(ctx, type, size) {
134
+ if (type === "square") {
135
+ ctx.fillRect(-size, -size, size * 2, size * 2);
136
+ return;
137
+ }
138
+
139
+ if (type === "star") {
140
+ drawStar(ctx, 5, size * 1.45, size * 0.58);
141
+ return;
142
+ }
143
+
144
+ if (type === "heart") {
145
+ drawHeart(ctx, size * 1.25);
146
+ return;
147
+ }
148
+
149
+ ctx.beginPath();
150
+ ctx.arc(0, 0, size, 0, Math.PI * 2);
151
+ ctx.fill();
152
+ }
153
+
154
+ function drawStar(ctx, points, outerRadius, innerRadius) {
155
+ ctx.beginPath();
156
+ for (let i = 0; i < points * 2; i += 1) {
157
+ const radius = i % 2 === 0 ? outerRadius : innerRadius;
158
+ const angle = (i * Math.PI) / points - Math.PI / 2;
159
+ const x = Math.cos(angle) * radius;
160
+ const y = Math.sin(angle) * radius;
161
+ if (i === 0) ctx.moveTo(x, y);
162
+ else ctx.lineTo(x, y);
163
+ }
164
+ ctx.closePath();
165
+ ctx.fill();
166
+ }
167
+
168
+ function drawHeart(ctx, size) {
169
+ ctx.beginPath();
170
+ ctx.moveTo(0, size * 0.5);
171
+ ctx.bezierCurveTo(size * 1.25, -size * 0.35, size * 0.65, -size * 1.35, 0, -size * 0.62);
172
+ ctx.bezierCurveTo(-size * 0.65, -size * 1.35, -size * 1.25, -size * 0.35, 0, size * 0.5);
173
+ ctx.fill();
174
+ }
@@ -0,0 +1,56 @@
1
+ import { MAX_PARTICLES } from "../config/defaults.js";
2
+ import { Particle } from "./Particle.js";
3
+
4
+ export class ParticleSystem {
5
+ constructor(factory, maxParticles = MAX_PARTICLES) {
6
+ this.factory = factory;
7
+ this.pool = Array.from({ length: maxParticles }, () => new Particle());
8
+ this.activeCount = 0;
9
+ }
10
+
11
+ spawn(options) {
12
+ const particle = this.pool.find((item) => !item.active);
13
+ if (!particle) return false;
14
+ particle.reset(options);
15
+ this.activeCount += 1;
16
+ return true;
17
+ }
18
+
19
+ addExplosion(particles) {
20
+ for (const particle of particles) {
21
+ this.spawn(particle);
22
+ }
23
+ }
24
+
25
+ update(step, config) {
26
+ const subBursts = [];
27
+ let active = 0;
28
+
29
+ for (const particle of this.pool) {
30
+ if (!particle.active) continue;
31
+ const burst = particle.update(step, config);
32
+ if (burst) subBursts.push(burst);
33
+ if (particle.active) active += 1;
34
+ }
35
+
36
+ this.activeCount = active;
37
+
38
+ for (const burst of subBursts.slice(0, 12)) {
39
+ this.addExplosion(this.factory.createSubExplosion(burst.x, burst.y, burst.source));
40
+ }
41
+ }
42
+
43
+ draw(ctx, config) {
44
+ for (const particle of this.pool) {
45
+ particle.draw(ctx, config);
46
+ }
47
+ }
48
+
49
+ clear() {
50
+ for (const particle of this.pool) {
51
+ particle.active = false;
52
+ particle.trail.length = 0;
53
+ }
54
+ this.activeCount = 0;
55
+ }
56
+ }
@@ -0,0 +1,44 @@
1
+ export class Renderer {
2
+ constructor(canvas, backgroundManager) {
3
+ this.canvas = canvas;
4
+ this.ctx = canvas.getContext("2d", { alpha: false });
5
+ this.backgroundManager = backgroundManager;
6
+ this.width = 0;
7
+ this.height = 0;
8
+ this.pixelRatio = 1;
9
+ }
10
+
11
+ resize() {
12
+ const rect = this.canvas.getBoundingClientRect();
13
+ this.pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
14
+ this.width = Math.max(1, Math.floor(rect.width));
15
+ this.height = Math.max(1, Math.floor(rect.height));
16
+ this.canvas.width = Math.floor(this.width * this.pixelRatio);
17
+ this.canvas.height = Math.floor(this.height * this.pixelRatio);
18
+ this.ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
19
+ this.backgroundManager.resize(this.width, this.height);
20
+ }
21
+
22
+ clearFrame(config, forceFullBackground = false) {
23
+ if (!config.enableTrails || forceFullBackground) {
24
+ this.backgroundManager.draw(this.ctx, this.width, this.height, config);
25
+ return;
26
+ }
27
+
28
+ this.ctx.save();
29
+ this.ctx.globalCompositeOperation = "source-over";
30
+ this.ctx.fillStyle = `rgba(3, 6, 12, ${config.fadeTrail})`;
31
+ this.ctx.fillRect(0, 0, this.width, this.height);
32
+ this.ctx.restore();
33
+ }
34
+
35
+ draw(config, particleSystem, shells) {
36
+ this.ctx.save();
37
+ this.ctx.globalCompositeOperation = config.glowIntensity > 0 ? "lighter" : "source-over";
38
+ for (const shell of shells) {
39
+ shell.draw(this.ctx, config.showLaunchTrail);
40
+ }
41
+ particleSystem.draw(this.ctx, config);
42
+ this.ctx.restore();
43
+ }
44
+ }