dopaminejs 1.1.0 → 2.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.
- package/dist/dopamine.js +1947 -0
- package/dist/dopamine.umd.cjs +45 -0
- package/package.json +24 -32
- package/src/core/Animator.js +62 -0
- package/src/core/Collider.js +50 -0
- package/src/core/Component.js +38 -0
- package/src/core/DopamineKernel.js +153 -0
- package/src/core/EventBus.js +183 -0
- package/src/core/Game.js +89 -0
- package/src/core/GameObject.js +83 -0
- package/src/core/PluginRegistry.js +197 -0
- package/src/core/Scene.js +65 -0
- package/src/core/Sprite.js +51 -0
- package/src/core/SystemRegistry.js +242 -0
- package/src/core/Vector2.js +50 -0
- package/src/{audio → dopamine/audio}/SoundManager.js +1 -3
- package/src/dopamine/components/ParticleEmitter.js +50 -0
- package/src/dopamine/components/ScreenShake.js +49 -0
- package/src/dopamine/components/index.js +4 -0
- package/src/dopamine/effects/ParticleSystem.js +327 -0
- package/src/dopamine/effects/WebGLParticleSystem.js +348 -0
- package/src/dopamine/index.js +62 -0
- package/src/dopamine/utils/TextureGenerator.js +90 -0
- package/src/index.js +34 -62
- package/src/interfaces/IAudioSystem.js +58 -0
- package/src/interfaces/IParticleSystem.js +51 -0
- package/src/interfaces/IPhysicsSystem.js +52 -0
- package/src/interfaces/ISystem.js +48 -0
- package/src/interfaces/index.js +7 -0
- package/src/renderer/Renderer.js +37 -0
- package/src/systems/Director.js +27 -0
- package/src/systems/Input.js +77 -0
- package/src/systems/Loader.js +44 -0
- package/src/systems/Physics.js +117 -0
- package/src/systems/Ticker.js +61 -0
- package/ARCHITECTURE.md +0 -71
- package/CHANGELOG.md +0 -27
- package/README.md +0 -213
- package/dist/dopamine.mjs +0 -724
- package/dist/dopamine.umd.js +0 -45
- package/src/effects/ParticleSystem.js +0 -257
- /package/src/{core → dopamine/core}/DataService.js +0 -0
- /package/src/{core → dopamine/core}/EventEmitter.js +0 -0
- /package/src/{core → dopamine/core}/RewardSystem.js +0 -0
- /package/src/{ui → dopamine/ui}/GameUI.js +0 -0
- /package/src/{ui → dopamine/ui}/dopamine.css +0 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Particle Effect System
|
|
3
|
+
* Creates satisfying visual feedback for achievements and rewards
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ParticleSystem {
|
|
7
|
+
constructor(config = {}) {
|
|
8
|
+
this.container = config.container ?
|
|
9
|
+
(typeof config.container === 'string' ? document.querySelector(config.container) : config.container)
|
|
10
|
+
: document.body;
|
|
11
|
+
|
|
12
|
+
this.canvasId = config.canvasId || 'dopamine-particle-canvas';
|
|
13
|
+
this.canvas = document.getElementById(this.canvasId);
|
|
14
|
+
|
|
15
|
+
if (!this.canvas) {
|
|
16
|
+
this.canvas = document.createElement('canvas');
|
|
17
|
+
this.canvas.id = this.canvasId;
|
|
18
|
+
this.canvas.style.pointerEvents = 'none';
|
|
19
|
+
this.canvas.style.zIndex = config.zIndex || '9999';
|
|
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);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.ctx = this.canvas.getContext('2d');
|
|
40
|
+
this.particles = [];
|
|
41
|
+
this.pool = []; // Object pool
|
|
42
|
+
this.sprites = new Map(); // key -> Image
|
|
43
|
+
this.customEffects = new Map(); // key -> callback
|
|
44
|
+
this.isAnimating = false;
|
|
45
|
+
|
|
46
|
+
this._resize();
|
|
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
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_resize() {
|
|
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
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register a custom sprite image
|
|
67
|
+
* @param {string} key
|
|
68
|
+
* @param {string} url
|
|
69
|
+
*/
|
|
70
|
+
registerSprite(key, url) {
|
|
71
|
+
const img = new Image();
|
|
72
|
+
img.src = url;
|
|
73
|
+
this.sprites.set(key, img);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a custom effect
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {Function} callback - (x, y, ...args) => void
|
|
80
|
+
*/
|
|
81
|
+
registerEffect(name, callback) {
|
|
82
|
+
this.customEffects.set(name, callback);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Play a registered effect
|
|
87
|
+
*/
|
|
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.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emit particles with custom config
|
|
99
|
+
* @param {Object} config
|
|
100
|
+
*/
|
|
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;
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < count; i++) {
|
|
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);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._startAnimation();
|
|
138
|
+
}
|
|
139
|
+
|
|
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
|
+
|
|
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
|
|
208
|
+
for (let i = 0; i < count; i++) {
|
|
209
|
+
const p = this._getParticle();
|
|
210
|
+
const angle = (Math.PI * 2 / count) * i;
|
|
211
|
+
const speed = 4;
|
|
212
|
+
|
|
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);
|
|
228
|
+
}
|
|
229
|
+
this._startAnimation();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_startAnimation() {
|
|
233
|
+
if (!this.isAnimating) {
|
|
234
|
+
this.isAnimating = true;
|
|
235
|
+
this._animate();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_animate() {
|
|
240
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
241
|
+
|
|
242
|
+
for (let i = this.particles.length - 1; i >= 0; i--) {
|
|
243
|
+
const p = this.particles[i];
|
|
244
|
+
|
|
245
|
+
// Physics
|
|
246
|
+
p.vy += p.gravity;
|
|
247
|
+
p.x += p.vx;
|
|
248
|
+
p.y += p.vy;
|
|
249
|
+
p.life -= p.decay;
|
|
250
|
+
|
|
251
|
+
if (p.rotationSpeed) {
|
|
252
|
+
p.rotation += p.rotationSpeed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Death
|
|
256
|
+
if (p.life <= 0) {
|
|
257
|
+
this._recycleParticle(p);
|
|
258
|
+
this.particles.splice(i, 1);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Draw
|
|
263
|
+
this.ctx.save();
|
|
264
|
+
this.ctx.globalAlpha = p.life;
|
|
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
|
+
}
|
|
273
|
+
} else if (p.type === 'star') {
|
|
274
|
+
this.ctx.fillStyle = p.color;
|
|
275
|
+
this._drawStarPath(0, 0, 5, p.size, p.size / 2);
|
|
276
|
+
this.ctx.fill();
|
|
277
|
+
} else {
|
|
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
|
|
282
|
+
this.ctx.beginPath();
|
|
283
|
+
this.ctx.arc(0, 0, p.size, 0, Math.PI * 2);
|
|
284
|
+
this.ctx.fill();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.ctx.restore();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (this.particles.length > 0) {
|
|
291
|
+
requestAnimationFrame(() => this._animate());
|
|
292
|
+
} else {
|
|
293
|
+
this.isAnimating = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_drawStarPath(cx, cy, spikes, outerRadius, innerRadius) {
|
|
298
|
+
let rot = Math.PI / 2 * 3;
|
|
299
|
+
let x = cx;
|
|
300
|
+
let y = cy;
|
|
301
|
+
const step = Math.PI / spikes;
|
|
302
|
+
|
|
303
|
+
this.ctx.beginPath();
|
|
304
|
+
this.ctx.moveTo(cx, cy - outerRadius);
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < spikes; i++) {
|
|
307
|
+
x = cx + Math.cos(rot) * outerRadius;
|
|
308
|
+
y = cy + Math.sin(rot) * outerRadius;
|
|
309
|
+
this.ctx.lineTo(x, y);
|
|
310
|
+
rot += step;
|
|
311
|
+
|
|
312
|
+
x = cx + Math.cos(rot) * innerRadius;
|
|
313
|
+
y = cy + Math.sin(rot) * innerRadius;
|
|
314
|
+
this.ctx.lineTo(x, y);
|
|
315
|
+
rot += step;
|
|
316
|
+
}
|
|
317
|
+
this.ctx.lineTo(cx, cy - outerRadius);
|
|
318
|
+
this.ctx.closePath();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
clear() {
|
|
322
|
+
// Recycle all
|
|
323
|
+
this.particles.forEach(p => this._recycleParticle(p));
|
|
324
|
+
this.particles = [];
|
|
325
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebGL Particle System
|
|
3
|
+
*
|
|
4
|
+
* High-performance particle renderer using WebGL for 10,000+ particles
|
|
5
|
+
* Implements IParticleSystem interface
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class WebGLParticleSystem {
|
|
9
|
+
constructor(config = {}) {
|
|
10
|
+
this.maxParticles = config.maxParticles || 10000;
|
|
11
|
+
this.kernel = null;
|
|
12
|
+
|
|
13
|
+
// WebGL context and resources
|
|
14
|
+
this.canvas = null;
|
|
15
|
+
this.gl = null;
|
|
16
|
+
this.program = null;
|
|
17
|
+
this.particleBuffer = null;
|
|
18
|
+
|
|
19
|
+
// Particle data (flat arrays for GPU upload)
|
|
20
|
+
this.positions = new Float32Array(this.maxParticles * 2); // x, y
|
|
21
|
+
this.velocities = new Float32Array(this.maxParticles * 2); // vx, vy
|
|
22
|
+
this.colors = new Float32Array(this.maxParticles * 4); // r, g, b, a
|
|
23
|
+
this.sizes = new Float32Array(this.maxParticles);
|
|
24
|
+
this.lifetimes = new Float32Array(this.maxParticles);
|
|
25
|
+
this.ages = new Float32Array(this.maxParticles);
|
|
26
|
+
|
|
27
|
+
this.particleCount = 0;
|
|
28
|
+
this.registeredEffects = new Map();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ISystem interface
|
|
32
|
+
init(kernel) {
|
|
33
|
+
this.kernel = kernel;
|
|
34
|
+
this._initWebGL();
|
|
35
|
+
this._createShaders();
|
|
36
|
+
this._createBuffers();
|
|
37
|
+
|
|
38
|
+
console.log('[WebGLParticles] Initialized with max particles:', this.maxParticles);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
update(dt) {
|
|
42
|
+
// Update particle physics
|
|
43
|
+
for (let i = 0; i < this.particleCount; i++) {
|
|
44
|
+
// Update age
|
|
45
|
+
this.ages[i] += dt;
|
|
46
|
+
|
|
47
|
+
// Remove dead particles
|
|
48
|
+
if (this.ages[i] >= this.lifetimes[i]) {
|
|
49
|
+
this._removeParticle(i);
|
|
50
|
+
i--; // Recheck this index
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Update position
|
|
55
|
+
this.positions[i * 2] += this.velocities[i * 2] * dt;
|
|
56
|
+
this.positions[i * 2 + 1] += this.velocities[i * 2 + 1] * dt;
|
|
57
|
+
|
|
58
|
+
// Apply gravity
|
|
59
|
+
this.velocities[i * 2 + 1] += 500 * dt; // Gravity
|
|
60
|
+
|
|
61
|
+
// Fade out
|
|
62
|
+
const lifeRatio = this.ages[i] / this.lifetimes[i];
|
|
63
|
+
this.colors[i * 4 + 3] = 1 - lifeRatio; // Alpha fade
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Render
|
|
67
|
+
this._render();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
destroy() {
|
|
71
|
+
if (this.gl) {
|
|
72
|
+
this.gl.deleteProgram(this.program);
|
|
73
|
+
this.gl.deleteBuffer(this.particleBuffer);
|
|
74
|
+
}
|
|
75
|
+
if (this.canvas && this.canvas.parentNode) {
|
|
76
|
+
this.canvas.parentNode.removeChild(this.canvas);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// IParticleSystem interface
|
|
81
|
+
registerSprite(key, url) {
|
|
82
|
+
// WebGL particles use point sprites, not textures (for simplicity)
|
|
83
|
+
console.warn('[WebGLParticles] Sprite registration not yet implemented');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerEffect(name, callback) {
|
|
87
|
+
this.registeredEffects.set(name, callback);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
play(name, x, y, ...args) {
|
|
91
|
+
const effect = this.registeredEffects.get(name);
|
|
92
|
+
if (effect) {
|
|
93
|
+
effect.call(this, x, y, ...args);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
emit(config) {
|
|
98
|
+
const {
|
|
99
|
+
x = 0,
|
|
100
|
+
y = 0,
|
|
101
|
+
count = 10,
|
|
102
|
+
color = [1, 1, 1, 1],
|
|
103
|
+
size = 5,
|
|
104
|
+
lifetime = 1,
|
|
105
|
+
velocity = { x: 0, y: -100 },
|
|
106
|
+
spread = 50
|
|
107
|
+
} = config;
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < count; i++) {
|
|
110
|
+
if (this.particleCount >= this.maxParticles) break;
|
|
111
|
+
|
|
112
|
+
const idx = this.particleCount;
|
|
113
|
+
|
|
114
|
+
// Position
|
|
115
|
+
this.positions[idx * 2] = x + (Math.random() - 0.5) * spread;
|
|
116
|
+
this.positions[idx * 2 + 1] = y + (Math.random() - 0.5) * spread;
|
|
117
|
+
|
|
118
|
+
// Velocity
|
|
119
|
+
const angle = Math.random() * Math.PI * 2;
|
|
120
|
+
const speed = Math.random() * 200 + 100;
|
|
121
|
+
this.velocities[idx * 2] = Math.cos(angle) * speed + velocity.x;
|
|
122
|
+
this.velocities[idx * 2 + 1] = Math.sin(angle) * speed + velocity.y;
|
|
123
|
+
|
|
124
|
+
// Color
|
|
125
|
+
this.colors[idx * 4] = color[0];
|
|
126
|
+
this.colors[idx * 4 + 1] = color[1];
|
|
127
|
+
this.colors[idx * 4 + 2] = color[2];
|
|
128
|
+
this.colors[idx * 4 + 3] = color[3];
|
|
129
|
+
|
|
130
|
+
// Size and lifetime
|
|
131
|
+
this.sizes[idx] = size;
|
|
132
|
+
this.lifetimes[idx] = lifetime;
|
|
133
|
+
this.ages[idx] = 0;
|
|
134
|
+
|
|
135
|
+
this.particleCount++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clear() {
|
|
140
|
+
this.particleCount = 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Built-in effects
|
|
144
|
+
confetti(x, y, count = 50) {
|
|
145
|
+
const colors = [
|
|
146
|
+
[1, 0, 0, 1], // Red
|
|
147
|
+
[0, 1, 0, 1], // Green
|
|
148
|
+
[0, 0, 1, 1], // Blue
|
|
149
|
+
[1, 1, 0, 1], // Yellow
|
|
150
|
+
[1, 0, 1, 1], // Magenta
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < count; i++) {
|
|
154
|
+
this.emit({
|
|
155
|
+
x, y,
|
|
156
|
+
count: 1,
|
|
157
|
+
color: colors[Math.floor(Math.random() * colors.length)],
|
|
158
|
+
size: Math.random() * 8 + 4,
|
|
159
|
+
lifetime: Math.random() * 2 + 1,
|
|
160
|
+
velocity: { x: 0, y: -300 },
|
|
161
|
+
spread: 100
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
sparkle(x, y, count = 20, color = [1, 1, 1, 1]) {
|
|
167
|
+
this.emit({
|
|
168
|
+
x, y,
|
|
169
|
+
count,
|
|
170
|
+
color,
|
|
171
|
+
size: 3,
|
|
172
|
+
lifetime: 0.5,
|
|
173
|
+
velocity: { x: 0, y: -50 },
|
|
174
|
+
spread: 30
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Private methods
|
|
179
|
+
_initWebGL() {
|
|
180
|
+
// Create overlay canvas
|
|
181
|
+
this.canvas = document.createElement('canvas');
|
|
182
|
+
this.canvas.style.cssText = `
|
|
183
|
+
position: fixed;
|
|
184
|
+
top: 0;
|
|
185
|
+
left: 0;
|
|
186
|
+
width: 100%;
|
|
187
|
+
height: 100%;
|
|
188
|
+
pointer-events: none;
|
|
189
|
+
z-index: 9999;
|
|
190
|
+
`;
|
|
191
|
+
this.canvas.width = window.innerWidth;
|
|
192
|
+
this.canvas.height = window.innerHeight;
|
|
193
|
+
document.body.appendChild(this.canvas);
|
|
194
|
+
|
|
195
|
+
// Get WebGL context
|
|
196
|
+
this.gl = this.canvas.getContext('webgl') || this.canvas.getContext('experimental-webgl');
|
|
197
|
+
if (!this.gl) {
|
|
198
|
+
console.error('[WebGLParticles] WebGL not supported');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Setup WebGL state
|
|
203
|
+
this.gl.enable(this.gl.BLEND);
|
|
204
|
+
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
|
|
205
|
+
|
|
206
|
+
// Handle resize
|
|
207
|
+
window.addEventListener('resize', () => {
|
|
208
|
+
this.canvas.width = window.innerWidth;
|
|
209
|
+
this.canvas.height = window.innerHeight;
|
|
210
|
+
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_createShaders() {
|
|
215
|
+
const vertexShaderSource = `
|
|
216
|
+
attribute vec2 a_position;
|
|
217
|
+
attribute vec4 a_color;
|
|
218
|
+
attribute float a_size;
|
|
219
|
+
|
|
220
|
+
uniform vec2 u_resolution;
|
|
221
|
+
|
|
222
|
+
varying vec4 v_color;
|
|
223
|
+
|
|
224
|
+
void main() {
|
|
225
|
+
// Convert from pixels to clip space
|
|
226
|
+
vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0;
|
|
227
|
+
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
|
|
228
|
+
gl_PointSize = a_size;
|
|
229
|
+
v_color = a_color;
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
const fragmentShaderSource = `
|
|
234
|
+
precision mediump float;
|
|
235
|
+
varying vec4 v_color;
|
|
236
|
+
|
|
237
|
+
void main() {
|
|
238
|
+
// Circular point sprite
|
|
239
|
+
vec2 coord = gl_PointCoord - vec2(0.5);
|
|
240
|
+
if (length(coord) > 0.5) {
|
|
241
|
+
discard;
|
|
242
|
+
}
|
|
243
|
+
gl_FragColor = v_color;
|
|
244
|
+
}
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const vertexShader = this._compileShader(this.gl.VERTEX_SHADER, vertexShaderSource);
|
|
248
|
+
const fragmentShader = this._compileShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource);
|
|
249
|
+
|
|
250
|
+
this.program = this.gl.createProgram();
|
|
251
|
+
this.gl.attachShader(this.program, vertexShader);
|
|
252
|
+
this.gl.attachShader(this.program, fragmentShader);
|
|
253
|
+
this.gl.linkProgram(this.program);
|
|
254
|
+
|
|
255
|
+
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
|
256
|
+
console.error('[WebGLParticles] Shader program failed to link');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_compileShader(type, source) {
|
|
261
|
+
const shader = this.gl.createShader(type);
|
|
262
|
+
this.gl.shaderSource(shader, source);
|
|
263
|
+
this.gl.compileShader(shader);
|
|
264
|
+
|
|
265
|
+
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
|
266
|
+
console.error('[WebGLParticles] Shader compilation error:', this.gl.getShaderInfoLog(shader));
|
|
267
|
+
this.gl.deleteShader(shader);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return shader;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_createBuffers() {
|
|
275
|
+
this.particleBuffer = this.gl.createBuffer();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_render() {
|
|
279
|
+
const gl = this.gl;
|
|
280
|
+
|
|
281
|
+
// Clear canvas
|
|
282
|
+
gl.clearColor(0, 0, 0, 0);
|
|
283
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
284
|
+
|
|
285
|
+
if (this.particleCount === 0) return;
|
|
286
|
+
|
|
287
|
+
// Use shader program
|
|
288
|
+
gl.useProgram(this.program);
|
|
289
|
+
|
|
290
|
+
// Set resolution uniform
|
|
291
|
+
const resolutionLocation = gl.getUniformLocation(this.program, 'u_resolution');
|
|
292
|
+
gl.uniform2f(resolutionLocation, this.canvas.width, this.canvas.height);
|
|
293
|
+
|
|
294
|
+
// Interleave particle data for GPU
|
|
295
|
+
const vertexData = new Float32Array(this.particleCount * 7); // x, y, r, g, b, a, size
|
|
296
|
+
for (let i = 0; i < this.particleCount; i++) {
|
|
297
|
+
vertexData[i * 7] = this.positions[i * 2];
|
|
298
|
+
vertexData[i * 7 + 1] = this.positions[i * 2 + 1];
|
|
299
|
+
vertexData[i * 7 + 2] = this.colors[i * 4];
|
|
300
|
+
vertexData[i * 7 + 3] = this.colors[i * 4 + 1];
|
|
301
|
+
vertexData[i * 7 + 4] = this.colors[i * 4 + 2];
|
|
302
|
+
vertexData[i * 7 + 5] = this.colors[i * 4 + 3];
|
|
303
|
+
vertexData[i * 7 + 6] = this.sizes[i];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Upload to GPU
|
|
307
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.particleBuffer);
|
|
308
|
+
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.DYNAMIC_DRAW);
|
|
309
|
+
|
|
310
|
+
// Setup attributes
|
|
311
|
+
const stride = 7 * 4; // 7 floats * 4 bytes
|
|
312
|
+
|
|
313
|
+
const positionLocation = gl.getAttribLocation(this.program, 'a_position');
|
|
314
|
+
gl.enableVertexAttribArray(positionLocation);
|
|
315
|
+
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, stride, 0);
|
|
316
|
+
|
|
317
|
+
const colorLocation = gl.getAttribLocation(this.program, 'a_color');
|
|
318
|
+
gl.enableVertexAttribArray(colorLocation);
|
|
319
|
+
gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, stride, 2 * 4);
|
|
320
|
+
|
|
321
|
+
const sizeLocation = gl.getAttribLocation(this.program, 'a_size');
|
|
322
|
+
gl.enableVertexAttribArray(sizeLocation);
|
|
323
|
+
gl.vertexAttribPointer(sizeLocation, 1, gl.FLOAT, false, stride, 6 * 4);
|
|
324
|
+
|
|
325
|
+
// Draw particles
|
|
326
|
+
gl.drawArrays(gl.POINTS, 0, this.particleCount);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_removeParticle(index) {
|
|
330
|
+
// Swap with last particle and decrease count
|
|
331
|
+
const last = this.particleCount - 1;
|
|
332
|
+
if (index !== last) {
|
|
333
|
+
// Copy last particle to this index
|
|
334
|
+
this.positions[index * 2] = this.positions[last * 2];
|
|
335
|
+
this.positions[index * 2 + 1] = this.positions[last * 2 + 1];
|
|
336
|
+
this.velocities[index * 2] = this.velocities[last * 2];
|
|
337
|
+
this.velocities[index * 2 + 1] = this.velocities[last * 2 + 1];
|
|
338
|
+
this.colors[index * 4] = this.colors[last * 4];
|
|
339
|
+
this.colors[index * 4 + 1] = this.colors[last * 4 + 1];
|
|
340
|
+
this.colors[index * 4 + 2] = this.colors[last * 4 + 2];
|
|
341
|
+
this.colors[index * 4 + 3] = this.colors[last * 4 + 3];
|
|
342
|
+
this.sizes[index] = this.sizes[last];
|
|
343
|
+
this.lifetimes[index] = this.lifetimes[last];
|
|
344
|
+
this.ages[index] = this.ages[last];
|
|
345
|
+
}
|
|
346
|
+
this.particleCount--;
|
|
347
|
+
}
|
|
348
|
+
}
|