dopaminejs 1.0.2 → 1.2.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.
- package/ARCHITECTURE.md +71 -0
- package/CHANGELOG.md +36 -0
- package/README.md +73 -0
- package/dist/dopamine.mjs +360 -283
- package/dist/dopamine.umd.js +12 -12
- package/package.json +4 -2
- package/src/audio/SoundManager.js +118 -12
- package/src/effects/ParticleSystem.js +201 -131
|
@@ -5,153 +5,227 @@
|
|
|
5
5
|
|
|
6
6
|
export class ParticleSystem {
|
|
7
7
|
constructor(config = {}) {
|
|
8
|
+
this.container = config.container ?
|
|
9
|
+
(typeof config.container === 'string' ? document.querySelector(config.container) : config.container)
|
|
10
|
+
: document.body;
|
|
11
|
+
|
|
8
12
|
this.canvasId = config.canvasId || 'dopamine-particle-canvas';
|
|
9
13
|
this.canvas = document.getElementById(this.canvasId);
|
|
10
14
|
|
|
11
15
|
if (!this.canvas) {
|
|
12
|
-
// Create overlay canvas if it doesn't exist
|
|
13
16
|
this.canvas = document.createElement('canvas');
|
|
14
17
|
this.canvas.id = this.canvasId;
|
|
15
|
-
this.canvas.style.position = 'fixed';
|
|
16
|
-
this.canvas.style.top = '0';
|
|
17
|
-
this.canvas.style.left = '0';
|
|
18
|
-
this.canvas.style.width = '100%';
|
|
19
|
-
this.canvas.style.height = '100%';
|
|
20
18
|
this.canvas.style.pointerEvents = 'none';
|
|
21
19
|
this.canvas.style.zIndex = config.zIndex || '9999';
|
|
22
|
-
|
|
20
|
+
|
|
21
|
+
if (this.container === document.body) {
|
|
22
|
+
this.canvas.style.position = 'fixed';
|
|
23
|
+
this.canvas.style.top = '0';
|
|
24
|
+
this.canvas.style.left = '0';
|
|
25
|
+
} else {
|
|
26
|
+
this.canvas.style.position = 'absolute';
|
|
27
|
+
this.canvas.style.top = '0';
|
|
28
|
+
this.canvas.style.left = '0';
|
|
29
|
+
// Ensure container is positioned
|
|
30
|
+
const style = window.getComputedStyle(this.container);
|
|
31
|
+
if (style.position === 'static') {
|
|
32
|
+
this.container.style.position = 'relative';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.container.appendChild(this.canvas);
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
this.ctx = this.canvas.getContext('2d');
|
|
26
40
|
this.particles = [];
|
|
41
|
+
this.pool = []; // Object pool
|
|
42
|
+
this.sprites = new Map(); // key -> Image
|
|
43
|
+
this.customEffects = new Map(); // key -> callback
|
|
27
44
|
this.isAnimating = false;
|
|
28
45
|
|
|
29
46
|
this._resize();
|
|
30
|
-
|
|
47
|
+
// Use ResizeObserver for container resizing if supported, fallback to window resize
|
|
48
|
+
if (window.ResizeObserver && this.container !== document.body) {
|
|
49
|
+
new ResizeObserver(() => this._resize()).observe(this.container);
|
|
50
|
+
} else {
|
|
51
|
+
window.addEventListener('resize', () => this._resize());
|
|
52
|
+
}
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
_resize() {
|
|
34
|
-
this.
|
|
35
|
-
|
|
56
|
+
if (this.container === document.body) {
|
|
57
|
+
this.canvas.width = window.innerWidth;
|
|
58
|
+
this.canvas.height = window.innerHeight;
|
|
59
|
+
} else {
|
|
60
|
+
this.canvas.width = this.container.clientWidth;
|
|
61
|
+
this.canvas.height = this.container.clientHeight;
|
|
62
|
+
}
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
/**
|
|
39
|
-
*
|
|
66
|
+
* Register a custom sprite image
|
|
67
|
+
* @param {string} key
|
|
68
|
+
* @param {string} url
|
|
40
69
|
*/
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.particles.push({
|
|
46
|
-
x, y,
|
|
47
|
-
vx: (Math.random() - 0.5) * 10,
|
|
48
|
-
vy: (Math.random() - 0.5) * 10 - 5,
|
|
49
|
-
rotation: Math.random() * Math.PI * 2,
|
|
50
|
-
rotationSpeed: (Math.random() - 0.5) * 0.2,
|
|
51
|
-
color: colors[Math.floor(Math.random() * colors.length)],
|
|
52
|
-
size: Math.random() * 8 + 4,
|
|
53
|
-
life: 1.0,
|
|
54
|
-
decay: 0.015 + Math.random() * 0.01,
|
|
55
|
-
gravity: 0.3,
|
|
56
|
-
type: 'confetti'
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
this._startAnimation();
|
|
70
|
+
registerSprite(key, url) {
|
|
71
|
+
const img = new Image();
|
|
72
|
+
img.src = url;
|
|
73
|
+
this.sprites.set(key, img);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/**
|
|
64
|
-
*
|
|
77
|
+
* Register a custom effect
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {Function} callback - (x, y, ...args) => void
|
|
65
80
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const angle = (Math.PI * 2 / count) * i;
|
|
69
|
-
const speed = 2 + Math.random() * 3;
|
|
70
|
-
|
|
71
|
-
this.particles.push({
|
|
72
|
-
x, y,
|
|
73
|
-
vx: Math.cos(angle) * speed,
|
|
74
|
-
vy: Math.sin(angle) * speed,
|
|
75
|
-
color,
|
|
76
|
-
size: Math.random() * 4 + 2,
|
|
77
|
-
life: 1.0,
|
|
78
|
-
decay: 0.02,
|
|
79
|
-
gravity: -0.1, // Slight upward drift
|
|
80
|
-
type: 'spark'
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
this._startAnimation();
|
|
81
|
+
registerEffect(name, callback) {
|
|
82
|
+
this.customEffects.set(name, callback);
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
/**
|
|
88
|
-
*
|
|
86
|
+
* Play a registered effect
|
|
89
87
|
*/
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
vy: Math.random() * -2 - 3,
|
|
97
|
-
color: '#ffd700',
|
|
98
|
-
size: 12,
|
|
99
|
-
life: 1.0,
|
|
100
|
-
decay: 0.012,
|
|
101
|
-
gravity: 0.4,
|
|
102
|
-
type: 'coin'
|
|
103
|
-
});
|
|
88
|
+
play(name, x, y, ...args) {
|
|
89
|
+
const effect = this.customEffects.get(name);
|
|
90
|
+
if (effect) {
|
|
91
|
+
effect(x, y, ...args);
|
|
92
|
+
} else {
|
|
93
|
+
console.warn(`[DopamineJS] Effect '${name}' not found.`);
|
|
104
94
|
}
|
|
105
|
-
|
|
106
|
-
this._startAnimation();
|
|
107
95
|
}
|
|
108
96
|
|
|
109
97
|
/**
|
|
110
|
-
*
|
|
98
|
+
* Emit particles with custom config
|
|
99
|
+
* @param {Object} config
|
|
111
100
|
*/
|
|
112
|
-
|
|
113
|
-
const
|
|
101
|
+
emit(config) {
|
|
102
|
+
const {
|
|
103
|
+
x, y,
|
|
104
|
+
count = 10,
|
|
105
|
+
speed = 5,
|
|
106
|
+
gravity = 0.1,
|
|
107
|
+
decay = 0.02,
|
|
108
|
+
life = 1.0,
|
|
109
|
+
color = '#ffd700',
|
|
110
|
+
sprite = null,
|
|
111
|
+
spread = Math.PI * 2,
|
|
112
|
+
angle = 0,
|
|
113
|
+
size = 5
|
|
114
|
+
} = config;
|
|
114
115
|
|
|
115
116
|
for (let i = 0; i < count; i++) {
|
|
116
|
-
this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
117
|
+
const p = this._getParticle();
|
|
118
|
+
const pAngle = angle + (Math.random() - 0.5) * spread;
|
|
119
|
+
const pSpeed = Math.random() * speed;
|
|
120
|
+
|
|
121
|
+
p.x = x;
|
|
122
|
+
p.y = y;
|
|
123
|
+
p.vx = Math.cos(pAngle) * pSpeed;
|
|
124
|
+
p.vy = Math.sin(pAngle) * pSpeed;
|
|
125
|
+
p.life = life;
|
|
126
|
+
p.decay = decay * (0.8 + Math.random() * 0.4); // Randomize decay slightly
|
|
127
|
+
p.gravity = gravity;
|
|
128
|
+
p.color = Array.isArray(color) ? color[Math.floor(Math.random() * color.length)] : color;
|
|
129
|
+
p.size = size * (0.8 + Math.random() * 0.4);
|
|
130
|
+
p.sprite = sprite;
|
|
131
|
+
p.rotation = Math.random() * Math.PI * 2;
|
|
132
|
+
p.rotationSpeed = (Math.random() - 0.5) * 0.2;
|
|
133
|
+
|
|
134
|
+
this.particles.push(p);
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
this._startAnimation();
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
_getParticle() {
|
|
141
|
+
if (this.pool.length > 0) {
|
|
142
|
+
return this.pool.pop();
|
|
143
|
+
}
|
|
144
|
+
return {}; // New particle object
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_recycleParticle(p) {
|
|
148
|
+
this.pool.push(p);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Built-in Effects ---
|
|
152
|
+
|
|
153
|
+
confetti(x, y, count = 50) {
|
|
154
|
+
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7dc6f', '#c06c84', '#6c5ce7'];
|
|
155
|
+
this.emit({
|
|
156
|
+
x, y, count,
|
|
157
|
+
color: colors,
|
|
158
|
+
speed: 10,
|
|
159
|
+
gravity: 0.3,
|
|
160
|
+
decay: 0.015,
|
|
161
|
+
size: 8,
|
|
162
|
+
spread: Math.PI * 2
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
coinShower(x, y, count = 10) {
|
|
167
|
+
this.emit({
|
|
168
|
+
x, y, count,
|
|
169
|
+
color: '#ffd700',
|
|
170
|
+
speed: 4,
|
|
171
|
+
gravity: 0.4,
|
|
172
|
+
decay: 0.012,
|
|
173
|
+
size: 12,
|
|
174
|
+
angle: -Math.PI / 2, // Upwards initially
|
|
175
|
+
spread: Math.PI / 2
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
sparkle(x, y, count = 20, color = '#ffd700') {
|
|
180
|
+
this.emit({
|
|
181
|
+
x, y, count,
|
|
182
|
+
color,
|
|
183
|
+
speed: 3,
|
|
184
|
+
gravity: -0.05, // Float up
|
|
185
|
+
decay: 0.03,
|
|
186
|
+
size: 3
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fire(x, y, count = 15) {
|
|
191
|
+
const colors = ['#ff6b00', '#ff8800', '#ffaa00', '#ff4400'];
|
|
192
|
+
this.emit({
|
|
193
|
+
x, y, count,
|
|
194
|
+
color: colors,
|
|
195
|
+
speed: 3,
|
|
196
|
+
gravity: -0.2,
|
|
197
|
+
decay: 0.04,
|
|
198
|
+
size: 5,
|
|
199
|
+
angle: -Math.PI / 2,
|
|
200
|
+
spread: Math.PI / 4
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
136
204
|
starBurst(x, y, count = 8) {
|
|
205
|
+
// Star burst is unique because of fixed angles, so we keep manual loop or use emit carefully
|
|
206
|
+
// For simplicity, let's use emit but we lose the perfect star shape distribution
|
|
207
|
+
// To keep it perfect, we'll manually push particles but use the pool
|
|
137
208
|
for (let i = 0; i < count; i++) {
|
|
209
|
+
const p = this._getParticle();
|
|
138
210
|
const angle = (Math.PI * 2 / count) * i;
|
|
139
211
|
const speed = 4;
|
|
140
212
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
213
|
+
p.x = x;
|
|
214
|
+
p.y = y;
|
|
215
|
+
p.vx = Math.cos(angle) * speed;
|
|
216
|
+
p.vy = Math.sin(angle) * speed;
|
|
217
|
+
p.life = 1.0;
|
|
218
|
+
p.decay = 0.03;
|
|
219
|
+
p.gravity = 0;
|
|
220
|
+
p.color = '#ffffff';
|
|
221
|
+
p.size = 15;
|
|
222
|
+
p.sprite = null;
|
|
223
|
+
p.type = 'star'; // Special type for drawing logic
|
|
224
|
+
p.rotation = angle;
|
|
225
|
+
p.rotationSpeed = 0;
|
|
226
|
+
|
|
227
|
+
this.particles.push(p);
|
|
153
228
|
}
|
|
154
|
-
|
|
155
229
|
this._startAnimation();
|
|
156
230
|
}
|
|
157
231
|
|
|
@@ -165,11 +239,10 @@ export class ParticleSystem {
|
|
|
165
239
|
_animate() {
|
|
166
240
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
167
241
|
|
|
168
|
-
// Update and draw particles
|
|
169
242
|
for (let i = this.particles.length - 1; i >= 0; i--) {
|
|
170
243
|
const p = this.particles[i];
|
|
171
244
|
|
|
172
|
-
//
|
|
245
|
+
// Physics
|
|
173
246
|
p.vy += p.gravity;
|
|
174
247
|
p.x += p.vx;
|
|
175
248
|
p.y += p.vy;
|
|
@@ -179,43 +252,41 @@ export class ParticleSystem {
|
|
|
179
252
|
p.rotation += p.rotationSpeed;
|
|
180
253
|
}
|
|
181
254
|
|
|
182
|
-
//
|
|
255
|
+
// Death
|
|
183
256
|
if (p.life <= 0) {
|
|
257
|
+
this._recycleParticle(p);
|
|
184
258
|
this.particles.splice(i, 1);
|
|
185
259
|
continue;
|
|
186
260
|
}
|
|
187
261
|
|
|
188
|
-
// Draw
|
|
262
|
+
// Draw
|
|
189
263
|
this.ctx.save();
|
|
190
264
|
this.ctx.globalAlpha = p.life;
|
|
191
|
-
this.ctx.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
200
|
-
this.ctx.fill();
|
|
201
|
-
// Add shine
|
|
202
|
-
this.ctx.fillStyle = '#ffee88';
|
|
203
|
-
this.ctx.beginPath();
|
|
204
|
-
this.ctx.arc(p.x - p.size / 3, p.y - p.size / 3, p.size / 3, 0, Math.PI * 2);
|
|
205
|
-
this.ctx.fill();
|
|
265
|
+
this.ctx.translate(p.x, p.y);
|
|
266
|
+
this.ctx.rotate(p.rotation);
|
|
267
|
+
|
|
268
|
+
if (p.sprite && this.sprites.has(p.sprite)) {
|
|
269
|
+
const img = this.sprites.get(p.sprite);
|
|
270
|
+
if (img.complete) {
|
|
271
|
+
this.ctx.drawImage(img, -p.size / 2, -p.size / 2, p.size, p.size);
|
|
272
|
+
}
|
|
206
273
|
} else if (p.type === 'star') {
|
|
207
|
-
this.
|
|
274
|
+
this.ctx.fillStyle = p.color;
|
|
275
|
+
this._drawStarPath(0, 0, 5, p.size, p.size / 2);
|
|
276
|
+
this.ctx.fill();
|
|
208
277
|
} else {
|
|
209
|
-
|
|
278
|
+
this.ctx.fillStyle = p.color;
|
|
279
|
+
// Default shape is square for performance/confetti, or circle
|
|
280
|
+
// Let's usefillRect for speed unless it's a coin/sparkle which usually implies circle
|
|
281
|
+
// For generic emit, let's assume circle if no sprite
|
|
210
282
|
this.ctx.beginPath();
|
|
211
|
-
this.ctx.arc(
|
|
283
|
+
this.ctx.arc(0, 0, p.size, 0, Math.PI * 2);
|
|
212
284
|
this.ctx.fill();
|
|
213
285
|
}
|
|
214
286
|
|
|
215
287
|
this.ctx.restore();
|
|
216
288
|
}
|
|
217
289
|
|
|
218
|
-
// Continue animation if particles remain
|
|
219
290
|
if (this.particles.length > 0) {
|
|
220
291
|
requestAnimationFrame(() => this._animate());
|
|
221
292
|
} else {
|
|
@@ -223,16 +294,18 @@ export class ParticleSystem {
|
|
|
223
294
|
}
|
|
224
295
|
}
|
|
225
296
|
|
|
226
|
-
|
|
227
|
-
let rot = Math.PI / 2 * 3
|
|
297
|
+
_drawStarPath(cx, cy, spikes, outerRadius, innerRadius) {
|
|
298
|
+
let rot = Math.PI / 2 * 3;
|
|
299
|
+
let x = cx;
|
|
300
|
+
let y = cy;
|
|
228
301
|
const step = Math.PI / spikes;
|
|
229
302
|
|
|
230
303
|
this.ctx.beginPath();
|
|
231
304
|
this.ctx.moveTo(cx, cy - outerRadius);
|
|
232
305
|
|
|
233
306
|
for (let i = 0; i < spikes; i++) {
|
|
234
|
-
|
|
235
|
-
|
|
307
|
+
x = cx + Math.cos(rot) * outerRadius;
|
|
308
|
+
y = cy + Math.sin(rot) * outerRadius;
|
|
236
309
|
this.ctx.lineTo(x, y);
|
|
237
310
|
rot += step;
|
|
238
311
|
|
|
@@ -241,16 +314,13 @@ export class ParticleSystem {
|
|
|
241
314
|
this.ctx.lineTo(x, y);
|
|
242
315
|
rot += step;
|
|
243
316
|
}
|
|
244
|
-
|
|
245
317
|
this.ctx.lineTo(cx, cy - outerRadius);
|
|
246
318
|
this.ctx.closePath();
|
|
247
|
-
this.ctx.fill();
|
|
248
319
|
}
|
|
249
320
|
|
|
250
|
-
/**
|
|
251
|
-
* Clear all particles
|
|
252
|
-
*/
|
|
253
321
|
clear() {
|
|
322
|
+
// Recycle all
|
|
323
|
+
this.particles.forEach(p => this._recycleParticle(p));
|
|
254
324
|
this.particles = [];
|
|
255
325
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
256
326
|
}
|