dopaminejs 1.0.2 → 1.1.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,71 @@
1
+ # DopamineJS Architecture & Roadmap 🏗️
2
+
3
+ **Vision**: To become the standard "Game Feel Engine" for the web—a plug-and-play layer that handles the "juice" (feedback, rewards, satisfaction) so developers can focus on core mechanics.
4
+
5
+ ## 🧠 Design Philosophy
6
+ 1. **Zero-Config Start**: It must work beautifully out of the box.
7
+ 2. **Infinite Extensibility**: Every default (sound, particle, UI style) must be replaceable.
8
+ 3. **Asset Agnostic**: Support synthesized sounds/shapes (no assets) AND custom assets (mp3/png).
9
+ 4. **AI-First**: APIs should be self-documenting and predictable for AI agents to use effectively.
10
+
11
+ ## 🗺️ Roadmap
12
+
13
+ ### Phase 1: Audio Extensibility (v1.1.0)
14
+ **Goal**: Allow developers to bring their own soundscapes.
15
+
16
+ - **[NEW] Asset Loader**: A lightweight preloader for audio files.
17
+ - **[UPDATE] SoundManager**:
18
+ - Add `registerSound(key, url)` method.
19
+ - Support `Howler.js` integration (optional) or robust Web Audio buffer caching.
20
+ - **Feature**: "Sound Packs" - Allow switching between 'Retro', 'Modern', 'Cute' preset packs.
21
+
22
+ ### Phase 2: Visual Customization (v1.2.0)
23
+ **Goal**: Break free from geometric primitives.
24
+
25
+ - **[UPDATE] ParticleSystem**:
26
+ - **Sprite Support**: Allow `image` or `sprite` properties in particle config.
27
+ - **Custom Emitters**: Define custom particle behaviors (gravity, velocity, life, decay) via JSON config.
28
+ - **Editor**: (Long term) A web-based particle editor that exports JSON for DopamineJS.
29
+
30
+ ### Phase 3: UI Theming & Templates (v1.3.0)
31
+ **Goal**: Make the UI fit any game art style.
32
+
33
+ - **[NEW] Theme Engine**:
34
+ - Use CSS Variables for all colors, fonts, and spacing.
35
+ - `Dopamine.setTheme('dark-cyberpunk')`
36
+ - **[UPDATE] GameUI**:
37
+ - **Icon Sets**: Allow passing an icon map (SVG strings or URLs) to replace default emojis.
38
+ - **Slots**: Allow developers to inject custom HTML into notifications or level-up screens.
39
+
40
+ ### Phase 4: The "Dopamine Ecosystem" (v2.0.0)
41
+ **Goal**: Community-driven content.
42
+
43
+ - **Plugin System**: Middleware for the `RewardSystem` (e.g., "BattlePass Plugin", "Leaderboard Plugin").
44
+ - **Backend Integration**: Webhooks for `onLevelUp` or `onAchievement` to validate rewards on a server.
45
+
46
+ ## 📝 Developer Guide: How to Extend (Current Best Practices)
47
+
48
+ While we build these features, here is how you can extend DopamineJS today:
49
+
50
+ ### Custom Achievements
51
+ You can already define any achievement logic you want:
52
+
53
+ ```javascript
54
+ achievements: {
55
+ 'die_100_times': {
56
+ name: 'You Tried',
57
+ icon: '<img src="skull.png" width="20">', // HTML is supported in icons!
58
+ check: (p) => p.stats.deaths >= 100
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Custom CSS
64
+ Override the default styles by adding your own CSS *after* importing DopamineJS:
65
+
66
+ ```css
67
+ /* Override the XP bar color */
68
+ .xp-bar-fill {
69
+ background: linear-gradient(90deg, #ff00cc, #3333ff) !important;
70
+ }
71
+ ```
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.1.0] - 2025-12-04
6
+ ### Added
7
+ - **Audio Extensibility**: Support for custom audio files (`.mp3`, `.wav`).
8
+ - `SoundManager.registerSound(key, url)`: Register custom assets.
9
+ - `SoundManager.play(key)`: Unified API to play custom sounds or fallback to synth.
10
+ - Support for `customSounds` in the initial configuration.
11
+
12
+ ## [1.0.2] - 2025-12-04
13
+ ### Fixed
14
+ - Fixed `npm run release` script failing due to missing `vitest` dependency.
15
+ - Uncommented `npm publish` in deployment script to ensure package is actually published to npm.
16
+
17
+ ## [1.0.1] - 2025-12-04
18
+ ### Added
19
+ - Initial release of DopamineJS.
20
+ - Core systems: `RewardSystem`, `ParticleSystem`, `SoundManager`, `GameUI`.
21
+ - Basic gamification features: XP, Levels, Streaks, Achievements.
22
+ - Built-in visual effects: Confetti, Coin Shower, Sparkles, Fire, Star Burst.
23
+ - Synthesized sound effects.
24
+ - Comprehensive README with "Vibe Coding" instructions for AI agents.
25
+
26
+ ## [1.0.0] - 2025-12-04
27
+ - Initial scaffold.
package/README.md CHANGED
@@ -121,6 +121,28 @@ Pre-loaded satisfying sound effects (generated synthetically or minimal assets).
121
121
  soundManager.playSuccess();
122
122
  ```
123
123
 
124
+ ### 🎧 Audio Customization (New in v1.1.0)
125
+ You can replace the default synthesized sounds with your own audio files (`.mp3`, `.wav`, etc.).
126
+
127
+ ```javascript
128
+ // 1. Register custom sounds
129
+ soundManager.registerSound('jump', '/assets/audio/jump_v2.mp3');
130
+ soundManager.registerSound('win', '/assets/audio/epic_win.wav');
131
+
132
+ // 2. Play them (auto-loads on first play if not preloaded)
133
+ soundManager.play('jump');
134
+
135
+ // 3. Or pass them in initial config
136
+ const app = new Dopamine({
137
+ sound: {
138
+ customSounds: {
139
+ 'click': '/assets/ui_click.mp3',
140
+ 'level_up': '/assets/level_up.mp3'
141
+ }
142
+ }
143
+ });
144
+ ```
145
+
124
146
  ### 🎨 Game UI
125
147
  Built-in UI components to show progress without coding HTML/CSS.
126
148
  - **Notifications**: "Achievement Unlocked", "Level Up", etc.
package/dist/dopamine.mjs CHANGED
@@ -25,11 +25,11 @@ class p {
25
25
  * @param {*} data - Event data
26
26
  */
27
27
  emit(t, e) {
28
- this.listeners.has(t) && this.listeners.get(t).forEach((i) => {
28
+ this.listeners.has(t) && this.listeners.get(t).forEach((s) => {
29
29
  try {
30
- i(e);
31
- } catch (s) {
32
- console.error(`Error in listener for event '${t}':`, s);
30
+ s(e);
31
+ } catch (i) {
32
+ console.error(`Error in listener for event '${t}':`, i);
33
33
  }
34
34
  });
35
35
  }
@@ -87,10 +87,10 @@ class m extends p {
87
87
  * @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
88
88
  */
89
89
  async addXP(t, e = "") {
90
- const i = this.player.level;
90
+ const s = this.player.level;
91
91
  this.player.xp += t;
92
- const s = this._calculateLevel(this.player.xp), a = s > i;
93
- return a && (this.player.level = s), await this.save(), this.emit("xp_gained", { amount: t, reason: e, leveledUp: a, newLevel: s }), a && this.emit("level_up", { oldLevel: i, newLevel: s }), { leveledUp: a, newLevel: s, xpGained: t };
92
+ const i = this._calculateLevel(this.player.xp), a = i > s;
93
+ return a && (this.player.level = i), await this.save(), this.emit("xp_gained", { amount: t, reason: e, leveledUp: a, newLevel: i }), a && this.emit("level_up", { oldLevel: s, newLevel: i }), { leveledUp: a, newLevel: i, xpGained: t };
94
94
  }
95
95
  /**
96
96
  * Calculate level from XP (exponential curve)
@@ -103,11 +103,11 @@ class m extends p {
103
103
  * Get XP needed for next level
104
104
  */
105
105
  getXPForNextLevel() {
106
- const t = this.player.level + 1, e = 50 * t * (t - 1), i = 50 * this.player.level * (this.player.level - 1);
106
+ const t = this.player.level + 1, e = 50 * t * (t - 1), s = 50 * this.player.level * (this.player.level - 1);
107
107
  return {
108
108
  total: e,
109
109
  needed: e - this.player.xp,
110
- progress: (this.player.xp - i) / (e - i)
110
+ progress: (this.player.xp - s) / (e - s)
111
111
  };
112
112
  }
113
113
  /**
@@ -117,21 +117,21 @@ class m extends p {
117
117
  */
118
118
  async recordGame(t, e) {
119
119
  this.player.stats[t] || (this.player.stats[t] = { totalPlays: 0, highScore: 0 });
120
- const i = this.player.stats[t];
121
- i.totalPlays++, this.player.totalGamesPlayed++, e.score !== void 0 && e.score > i.highScore && (i.highScore = e.score, this.emit("new_high_score", { gameName: t, score: e.score })), Object.keys(e).forEach((a) => {
122
- a !== "score" && (i[a] = (i[a] || 0) + (typeof e[a] == "number" ? e[a] : 0));
120
+ const s = this.player.stats[t];
121
+ s.totalPlays++, this.player.totalGamesPlayed++, e.score !== void 0 && e.score > s.highScore && (s.highScore = e.score, this.emit("new_high_score", { gameName: t, score: e.score })), Object.keys(e).forEach((a) => {
122
+ a !== "score" && (s[a] = (s[a] || 0) + (typeof e[a] == "number" ? e[a] : 0));
123
123
  });
124
- let s = 10;
125
- e.score && (s += Math.floor(e.score / 5)), s = Math.floor(s * this.getStreakMultiplier()), await this.addXP(s, `Played ${t}`), await this.checkAchievements(t, e), await this.save();
124
+ let i = 10;
125
+ e.score && (i += Math.floor(e.score / 5)), i = Math.floor(i * this.getStreakMultiplier()), await this.addXP(i, `Played ${t}`), await this.checkAchievements(t, e), await this.save();
126
126
  }
127
127
  /**
128
128
  * Check and unlock achievements
129
129
  */
130
130
  async checkAchievements(t, e) {
131
- const i = [];
132
- for (const [s, a] of Object.entries(this.achievements))
133
- this.player.achievements[s] || a.check(this.player, t, e) && (await this.unlockAchievement(s), i.push(a));
134
- return i;
131
+ const s = [];
132
+ for (const [i, a] of Object.entries(this.achievements))
133
+ this.player.achievements[i] || a.check(this.player, t, e) && (await this.unlockAchievement(i), s.push(a));
134
+ return s;
135
135
  }
136
136
  /**
137
137
  * Unlock an achievement
@@ -174,8 +174,8 @@ class m extends p {
174
174
  const t = this._getTodayDateString(), e = this.player.streak.lastPlayDate;
175
175
  if (e === t)
176
176
  return;
177
- const i = this._getYesterdayDateString();
178
- e === i ? (this.player.streak.current++, this.player.streak.current > this.player.streak.longest && (this.player.streak.longest = this.player.streak.current)) : this.player.streak.current = 1, this.player.streak.lastPlayDate = t;
177
+ const s = this._getYesterdayDateString();
178
+ e === s ? (this.player.streak.current++, this.player.streak.current > this.player.streak.longest && (this.player.streak.longest = this.player.streak.current)) : this.player.streak.current = 1, this.player.streak.lastPlayDate = t;
179
179
  }
180
180
  /**
181
181
  * Get streak multiplier for XP
@@ -242,7 +242,7 @@ class m extends p {
242
242
  };
243
243
  }
244
244
  }
245
- class y {
245
+ class u {
246
246
  constructor(t = {}) {
247
247
  this.storage = t.storage || window.localStorage, this.prefix = t.prefix || "dopamine_";
248
248
  }
@@ -253,10 +253,10 @@ class y {
253
253
  */
254
254
  async save(t, e) {
255
255
  try {
256
- const i = JSON.stringify(e);
257
- return this.storage.setItem(this.prefix + t, i), !0;
258
- } catch (i) {
259
- return console.error("Error saving data:", i), !1;
256
+ const s = JSON.stringify(e);
257
+ return this.storage.setItem(this.prefix + t, s), !0;
258
+ } catch (s) {
259
+ return console.error("Error saving data:", s), !1;
260
260
  }
261
261
  }
262
262
  /**
@@ -266,10 +266,10 @@ class y {
266
266
  */
267
267
  async load(t, e = null) {
268
268
  try {
269
- const i = this.storage.getItem(this.prefix + t);
270
- return i ? JSON.parse(i) : e;
271
- } catch (i) {
272
- return console.error("Error loading data:", i), e;
269
+ const s = this.storage.getItem(this.prefix + t);
270
+ return s ? JSON.parse(s) : e;
271
+ } catch (s) {
272
+ return console.error("Error loading data:", s), e;
273
273
  }
274
274
  }
275
275
  /**
@@ -280,7 +280,7 @@ class y {
280
280
  this.storage.removeItem(this.prefix + t);
281
281
  }
282
282
  }
283
- class v {
283
+ class y {
284
284
  constructor(t) {
285
285
  this.particleSystem = t, this.container = null, this.xpBar = null, this.levelDisplay = null, this.streakDisplay = null, this.init();
286
286
  }
@@ -312,9 +312,9 @@ class v {
312
312
  /**
313
313
  * Update XP bar
314
314
  */
315
- updateXP(t, e, i) {
316
- const s = t / i * 100;
317
- this.xpBar.style.width = `${Math.min(100, s)}%`, this.xpText.textContent = `${t} / ${i} XP`, this.xpBar.style.animation = "none", setTimeout(() => {
315
+ updateXP(t, e, s) {
316
+ const i = t / s * 100;
317
+ this.xpBar.style.width = `${Math.min(100, i)}%`, this.xpText.textContent = `${t} / ${s} XP`, this.xpBar.style.animation = "none", setTimeout(() => {
318
318
  this.xpBar.style.animation = "xp-pulse 0.3s ease-out";
319
319
  }, 10);
320
320
  }
@@ -353,8 +353,8 @@ class v {
353
353
  </div>
354
354
  </div>
355
355
  `, document.body.appendChild(e), setTimeout(() => {
356
- const i = e.getBoundingClientRect();
357
- this.particleSystem.confetti(i.left + i.width / 2, i.top + i.height / 2, 40);
356
+ const s = e.getBoundingClientRect();
357
+ this.particleSystem.confetti(s.left + s.width / 2, s.top + s.height / 2, 40);
358
358
  }, 300), setTimeout(() => {
359
359
  e.style.animation = "slideOut 0.3s ease-in forwards", setTimeout(() => e.remove(), 300);
360
360
  }, 4e3);
@@ -363,35 +363,35 @@ class v {
363
363
  * Show level up screen
364
364
  */
365
365
  showLevelUp(t, e) {
366
- const i = document.createElement("div");
367
- i.className = "level-up-overlay", i.innerHTML = `
366
+ const s = document.createElement("div");
367
+ s.className = "level-up-overlay", s.innerHTML = `
368
368
  <div class="level-up-content">
369
369
  <div class="level-up-title">LEVEL UP!</div>
370
370
  <div class="level-up-number">${e}</div>
371
371
  <div class="level-up-subtitle">Amazing progress!</div>
372
372
  </div>
373
- `, document.body.appendChild(i), setTimeout(() => {
374
- for (let s = 0; s < 5; s++)
373
+ `, document.body.appendChild(s), setTimeout(() => {
374
+ for (let i = 0; i < 5; i++)
375
375
  setTimeout(() => {
376
376
  const a = Math.random() * window.innerWidth, n = Math.random() * window.innerHeight * 0.6;
377
377
  this.particleSystem.confetti(a, n, 30);
378
- }, s * 200);
378
+ }, i * 200);
379
379
  }, 300), setTimeout(() => {
380
- i.style.opacity = "0", setTimeout(() => i.remove(), 500);
380
+ s.style.opacity = "0", setTimeout(() => s.remove(), 500);
381
381
  }, 2500);
382
382
  }
383
383
  /**
384
384
  * Show floating text (e.g., "+50 XP", "COMBO!")
385
385
  */
386
- showFloatingText(t, e, i, s = "#ffd700", a = "24px") {
386
+ showFloatingText(t, e, s, i = "#ffd700", a = "24px") {
387
387
  const n = document.createElement("div");
388
- n.className = "floating-text", n.textContent = t, n.style.left = e + "px", n.style.top = i + "px", n.style.color = s, n.style.fontSize = a, document.body.appendChild(n), setTimeout(() => n.remove(), 2e3);
388
+ n.className = "floating-text", n.textContent = t, n.style.left = e + "px", n.style.top = s + "px", n.style.color = i, n.style.fontSize = a, document.body.appendChild(n), setTimeout(() => n.remove(), 2e3);
389
389
  }
390
390
  /**
391
391
  * Show combo multiplier
392
392
  */
393
- showCombo(t, e, i) {
394
- this.showFloatingText(`${t}× COMBO!`, e, i, "#ff6b6b", "32px"), this.particleSystem.fire(e, i, 20);
393
+ showCombo(t, e, s) {
394
+ this.showFloatingText(`${t}× COMBO!`, e, s, "#ff6b6b", "32px"), this.particleSystem.fire(e, s, 20);
395
395
  }
396
396
  /**
397
397
  * Show "near miss"indicator
@@ -402,15 +402,15 @@ class v {
402
402
  /**
403
403
  * Show lucky moment (random multiplier)
404
404
  */
405
- showLuckyMoment(t, e, i) {
406
- this.showFloatingText(`LUCKY ${t}×!`, e, i, "#ffd700", "36px"), this.particleSystem.starBurst(e, i, 12), this.particleSystem.confetti(e, i, 25);
405
+ showLuckyMoment(t, e, s) {
406
+ this.showFloatingText(`LUCKY ${t}×!`, e, s, "#ffd700", "36px"), this.particleSystem.starBurst(e, s, 12), this.particleSystem.confetti(e, s, 25);
407
407
  }
408
408
  /**
409
409
  * Show generic notification
410
410
  */
411
411
  showNotification(t, e = "normal") {
412
- const i = window.innerWidth / 2, s = window.innerHeight / 3;
413
- e === "legendary" ? this.showLuckyMoment(t, i, s) : e === "rare" ? this.showCombo(t, i, s) : this.showFloatingText(t, i, s, "#fff", "24px");
412
+ const s = window.innerWidth / 2, i = window.innerHeight / 3;
413
+ e === "legendary" ? this.showLuckyMoment(t, s, i) : e === "rare" ? this.showCombo(t, s, i) : this.showFloatingText(t, s, i, "#fff", "24px");
414
414
  }
415
415
  /**
416
416
  * Show Game Over Summary
@@ -418,19 +418,19 @@ class v {
418
418
  showSummary(t) {
419
419
  const e = document.createElement("div");
420
420
  e.className = "message-box", e.style.display = "flex", e.style.position = "absolute", e.style.top = "50%", e.style.left = "50%", e.style.transform = "translate(-50%, -50%)", e.style.zIndex = "1000";
421
- let i = "";
422
- t.metrics && (i = Object.entries(t.metrics).map(([s, a]) => `<div style="display:flex;justify-content:space-between;width:100%"><span>${s}:</span> <strong>${a}</strong></div>`).join("")), e.innerHTML = `
421
+ let s = "";
422
+ t.metrics && (s = Object.entries(t.metrics).map(([i, a]) => `<div style="display:flex;justify-content:space-between;width:100%"><span>${i}:</span> <strong>${a}</strong></div>`).join("")), e.innerHTML = `
423
423
  <h1>Game Over</h1>
424
424
  <div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${t.score}</div>
425
425
  <div style="width: 100%; margin-bottom: 20px;">
426
- ${i}
426
+ ${s}
427
427
  </div>
428
428
  <button class="btn" onclick="location.reload()">Play Again</button>
429
429
  <button class="btn" onclick="window.location.href='index.html'" style="margin-top: 10px; background: #543847;">Exit</button>
430
430
  `, document.body.appendChild(e);
431
431
  }
432
432
  }
433
- class u {
433
+ class v {
434
434
  constructor(t = {}) {
435
435
  this.canvasId = t.canvasId || "dopamine-particle-canvas", this.canvas = document.getElementById(this.canvasId), this.canvas || (this.canvas = document.createElement("canvas"), this.canvas.id = this.canvasId, this.canvas.style.position = "fixed", this.canvas.style.top = "0", this.canvas.style.left = "0", this.canvas.style.width = "100%", this.canvas.style.height = "100%", this.canvas.style.pointerEvents = "none", this.canvas.style.zIndex = t.zIndex || "9999", document.body.appendChild(this.canvas)), this.ctx = this.canvas.getContext("2d"), this.particles = [], this.isAnimating = !1, this._resize(), window.addEventListener("resize", () => this._resize());
436
436
  }
@@ -440,9 +440,9 @@ class u {
440
440
  /**
441
441
  * Confetti explosion (for achievements, level ups)
442
442
  */
443
- confetti(t, e, i = 50) {
444
- const s = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#f7dc6f", "#c06c84", "#6c5ce7"];
445
- for (let a = 0; a < i; a++)
443
+ confetti(t, e, s = 50) {
444
+ const i = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#f7dc6f", "#c06c84", "#6c5ce7"];
445
+ for (let a = 0; a < s; a++)
446
446
  this.particles.push({
447
447
  x: t,
448
448
  y: e,
@@ -450,7 +450,7 @@ class u {
450
450
  vy: (Math.random() - 0.5) * 10 - 5,
451
451
  rotation: Math.random() * Math.PI * 2,
452
452
  rotationSpeed: (Math.random() - 0.5) * 0.2,
453
- color: s[Math.floor(Math.random() * s.length)],
453
+ color: i[Math.floor(Math.random() * i.length)],
454
454
  size: Math.random() * 8 + 4,
455
455
  life: 1,
456
456
  decay: 0.015 + Math.random() * 0.01,
@@ -462,15 +462,15 @@ class u {
462
462
  /**
463
463
  * Sparkle effect (for score milestones, combos)
464
464
  */
465
- sparkle(t, e, i = 20, s = "#ffd700") {
466
- for (let a = 0; a < i; a++) {
467
- const n = Math.PI * 2 / i * a, r = 2 + Math.random() * 3;
465
+ sparkle(t, e, s = 20, i = "#ffd700") {
466
+ for (let a = 0; a < s; a++) {
467
+ const n = Math.PI * 2 / s * a, r = 2 + Math.random() * 3;
468
468
  this.particles.push({
469
469
  x: t,
470
470
  y: e,
471
471
  vx: Math.cos(n) * r,
472
472
  vy: Math.sin(n) * r,
473
- color: s,
473
+ color: i,
474
474
  size: Math.random() * 4 + 2,
475
475
  life: 1,
476
476
  decay: 0.02,
@@ -484,8 +484,8 @@ class u {
484
484
  /**
485
485
  * Coin shower (for collecting coins)
486
486
  */
487
- coinShower(t, e, i = 10) {
488
- for (let s = 0; s < i; s++)
487
+ coinShower(t, e, s = 10) {
488
+ for (let i = 0; i < s; i++)
489
489
  this.particles.push({
490
490
  x: t + (Math.random() - 0.5) * 100,
491
491
  y: e - Math.random() * 50,
@@ -503,15 +503,15 @@ class u {
503
503
  /**
504
504
  * Fire particles (for streaks, combos)
505
505
  */
506
- fire(t, e, i = 15) {
507
- const s = ["#ff6b00", "#ff8800", "#ffaa00", "#ff4400"];
508
- for (let a = 0; a < i; a++)
506
+ fire(t, e, s = 15) {
507
+ const i = ["#ff6b00", "#ff8800", "#ffaa00", "#ff4400"];
508
+ for (let a = 0; a < s; a++)
509
509
  this.particles.push({
510
510
  x: t + (Math.random() - 0.5) * 20,
511
511
  y: e,
512
512
  vx: (Math.random() - 0.5) * 2,
513
513
  vy: -Math.random() * 3 - 2,
514
- color: s[Math.floor(Math.random() * s.length)],
514
+ color: i[Math.floor(Math.random() * i.length)],
515
515
  size: Math.random() * 6 + 3,
516
516
  life: 1,
517
517
  decay: 0.025,
@@ -524,9 +524,9 @@ class u {
524
524
  /**
525
525
  * Star burst (for "perfect" moments)
526
526
  */
527
- starBurst(t, e, i = 8) {
528
- for (let s = 0; s < i; s++) {
529
- const a = Math.PI * 2 / i * s, n = 4;
527
+ starBurst(t, e, s = 8) {
528
+ for (let i = 0; i < s; i++) {
529
+ const a = Math.PI * 2 / s * i, n = 4;
530
530
  this.particles.push({
531
531
  x: t,
532
532
  y: e,
@@ -558,15 +558,15 @@ class u {
558
558
  }
559
559
  this.particles.length > 0 ? requestAnimationFrame(() => this._animate()) : this.isAnimating = !1;
560
560
  }
561
- _drawStar(t, e, i, s, a, n) {
561
+ _drawStar(t, e, s, i, a, n) {
562
562
  let r = Math.PI / 2 * 3 + n;
563
- const c = Math.PI / i;
564
- this.ctx.beginPath(), this.ctx.moveTo(t, e - s);
565
- for (let d = 0; d < i; d++) {
566
- let l = t + Math.cos(r) * s, h = e + Math.sin(r) * s;
563
+ const c = Math.PI / s;
564
+ this.ctx.beginPath(), this.ctx.moveTo(t, e - i);
565
+ for (let d = 0; d < s; d++) {
566
+ let l = t + Math.cos(r) * i, h = e + Math.sin(r) * i;
567
567
  this.ctx.lineTo(l, h), r += c, l = t + Math.cos(r) * a, h = e + Math.sin(r) * a, this.ctx.lineTo(l, h), r += c;
568
568
  }
569
- this.ctx.lineTo(t, e - s), this.ctx.closePath(), this.ctx.fill();
569
+ this.ctx.lineTo(t, e - i), this.ctx.closePath(), this.ctx.fill();
570
570
  }
571
571
  /**
572
572
  * Clear all particles
@@ -577,13 +577,17 @@ class u {
577
577
  }
578
578
  class f {
579
579
  constructor(t = {}) {
580
- this.audioContext = null, this.storageKey = t.storageKey || "dopamineSoundsMuted", this.muted = localStorage.getItem(this.storageKey) === "true";
580
+ this.audioContext = null, this.storageKey = t.storageKey || "dopamineSoundsMuted", this.muted = localStorage.getItem(this.storageKey) === "true", this.assets = /* @__PURE__ */ new Map(), this.soundPack = t.soundPack || "synth", this.customSounds = t.customSounds || {}, Object.keys(this.customSounds).length > 0 && this.preloadSounds(this.customSounds);
581
581
  }
582
582
  /**
583
583
  * Initialize Audio Context (must be called after user interaction)
584
584
  */
585
585
  initAudio() {
586
- this.audioContext || (this.audioContext = new (window.AudioContext || window.webkitAudioContext)());
586
+ if (!this.audioContext) {
587
+ const t = window.AudioContext || window.webkitAudioContext;
588
+ this.audioContext = new t();
589
+ }
590
+ this.audioContext.state === "suspended" && this.audioContext.resume();
587
591
  }
588
592
  /**
589
593
  * Toggle mute state
@@ -592,42 +596,102 @@ class f {
592
596
  toggleMute() {
593
597
  return this.muted = !this.muted, localStorage.setItem(this.storageKey, this.muted), this.muted;
594
598
  }
599
+ /**
600
+ * Register a custom sound URL
601
+ * @param {string} key - Unique identifier (e.g., 'jump', 'win')
602
+ * @param {string} url - Path to audio file
603
+ */
604
+ registerSound(t, e) {
605
+ this.customSounds[t] = e, this.audioContext && this.loadSound(t, e);
606
+ }
607
+ /**
608
+ * Preload multiple sounds
609
+ * @param {Object} soundMap - { key: url }
610
+ */
611
+ async preloadSounds(t) {
612
+ const e = Object.entries(t).map(([s, i]) => this.loadSound(s, i));
613
+ await Promise.allSettled(e);
614
+ }
615
+ /**
616
+ * Load and decode a sound file
617
+ */
618
+ async loadSound(t, e) {
619
+ try {
620
+ if (!this.audioContext)
621
+ return;
622
+ const i = await (await fetch(e)).arrayBuffer(), a = await this.audioContext.decodeAudioData(i);
623
+ this.assets.set(t, a);
624
+ } catch (s) {
625
+ console.warn(`[DopamineJS] Failed to load sound '${t}':`, s);
626
+ }
627
+ }
628
+ /**
629
+ * Play a sound by key (custom) or fallback to synth
630
+ * @param {string} key - 'jump', 'score', 'success', etc.
631
+ */
632
+ async play(t) {
633
+ if (this.muted) return;
634
+ if (this.initAudio(), this.assets.has(t)) {
635
+ this._playBuffer(this.assets.get(t));
636
+ return;
637
+ }
638
+ if (this.customSounds[t] && !this.assets.has(t) && (await this.loadSound(t, this.customSounds[t]), this.assets.has(t))) {
639
+ this._playBuffer(this.assets.get(t));
640
+ return;
641
+ }
642
+ const e = {
643
+ jump: () => this.playJump(!0),
644
+ // true = force synth
645
+ score: () => this.playScore(!0),
646
+ success: () => this.playSuccess(!0),
647
+ click: () => this.playClick(!0),
648
+ error: () => this.playError(!0),
649
+ gameover: () => this.playGameOver(!0)
650
+ };
651
+ e[t] && e[t]();
652
+ }
653
+ _playBuffer(t) {
654
+ const e = this.audioContext.createBufferSource();
655
+ e.buffer = t, e.connect(this.audioContext.destination), e.start(0);
656
+ }
595
657
  /**
596
658
  * Play a synthesized tone
597
- * @param {number} frequency
598
- * @param {number} duration
599
- * @param {string} type - 'sine', 'square', 'sawtooth', 'triangle'
600
- * @param {number} volume
601
659
  */
602
- playTone(t, e, i = "sine", s = 0.3) {
660
+ playTone(t, e, s = "sine", i = 0.3) {
603
661
  if (this.muted) return;
604
662
  this.initAudio();
605
663
  const a = this.audioContext.createOscillator(), n = this.audioContext.createGain();
606
- a.connect(n), n.connect(this.audioContext.destination), a.frequency.value = t, a.type = i, n.gain.setValueAtTime(s, this.audioContext.currentTime), n.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + e), a.start(this.audioContext.currentTime), a.stop(this.audioContext.currentTime + e);
664
+ a.connect(n), n.connect(this.audioContext.destination), a.frequency.value = t, a.type = s, n.gain.setValueAtTime(i, this.audioContext.currentTime), n.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + e), a.start(this.audioContext.currentTime), a.stop(this.audioContext.currentTime + e);
607
665
  }
608
- // --- Presets ---
609
- playJump() {
666
+ // --- Presets (Synth) ---
667
+ playJump(t = !1) {
668
+ if (!t && this.assets.has("jump")) return this.play("jump");
610
669
  this.playTone(400, 0.1, "square", 0.2);
611
670
  }
612
- playScore() {
671
+ playScore(t = !1) {
672
+ if (!t && this.assets.has("score")) return this.play("score");
613
673
  this.playTone(800, 0.15, "sine", 0.25), setTimeout(() => this.playTone(1e3, 0.15, "sine", 0.25), 50);
614
674
  }
615
- playGameOver() {
675
+ playGameOver(t = !1) {
676
+ if (!t && this.assets.has("gameover")) return this.play("gameover");
616
677
  this.playTone(300, 0.2, "sawtooth", 0.3), setTimeout(() => this.playTone(200, 0.2, "sawtooth", 0.3), 100), setTimeout(() => this.playTone(150, 0.3, "sawtooth", 0.3), 200);
617
678
  }
618
- playClick() {
679
+ playClick(t = !1) {
680
+ if (!t && this.assets.has("click")) return this.play("click");
619
681
  this.playTone(600, 0.05, "sine", 0.1);
620
682
  }
621
- playSuccess() {
683
+ playSuccess(t = !1) {
684
+ if (!t && this.assets.has("success")) return this.play("success");
622
685
  this.playTone(600, 0.1, "sine", 0.2), setTimeout(() => this.playTone(800, 0.2, "sine", 0.2), 100);
623
686
  }
624
- playError() {
687
+ playError(t = !1) {
688
+ if (!t && this.assets.has("error")) return this.play("error");
625
689
  this.playTone(200, 0.2, "sawtooth", 0.3);
626
690
  }
627
691
  }
628
692
  class g {
629
693
  constructor(t = {}) {
630
- this.config = t, this.dataService = new y(t.data || {}), this.rewardSystem = new m(this.dataService, t.rewards || {}), this.soundManager = new f(t.sound || {}), this.particleSystem = new u(t.particles || {}), this.gameUI = new v(this.particleSystem), this._bindEvents();
694
+ this.config = t, this.dataService = new u(t.data || {}), this.rewardSystem = new m(this.dataService, t.rewards || {}), this.soundManager = new f(t.sound || {}), this.particleSystem = new v(t.particles || {}), this.gameUI = new y(this.particleSystem), this._bindEvents();
631
695
  }
632
696
  async init() {
633
697
  return await this.rewardSystem.init(), console.log("DopamineJS Initialized 🚀"), {
@@ -639,8 +703,8 @@ class g {
639
703
  }
640
704
  _bindEvents() {
641
705
  this.rewardSystem.on("xp_gained", (t) => {
642
- const { total: e, needed: i } = this.rewardSystem.getXPForNextLevel();
643
- this.gameUI.updateXP(this.rewardSystem.player.xp, i, e), t.xpGained > 0;
706
+ const { total: e, needed: s } = this.rewardSystem.getXPForNextLevel();
707
+ this.gameUI.updateXP(this.rewardSystem.player.xp, s, e), t.xpGained > 0;
644
708
  }), this.rewardSystem.on("level_up", (t) => {
645
709
  this.gameUI.updateLevel(t.newLevel), this.gameUI.showLevelUp(t.oldLevel, t.newLevel);
646
710
  }), this.rewardSystem.on("achievement_unlocked", (t) => {
@@ -651,9 +715,9 @@ class g {
651
715
  }
652
716
  }
653
717
  export {
654
- y as DataService,
655
- v as GameUI,
656
- u as ParticleSystem,
718
+ u as DataService,
719
+ y as GameUI,
720
+ v as ParticleSystem,
657
721
  m as RewardSystem,
658
722
  f as SoundManager,
659
723
  g as default
@@ -1,4 +1,4 @@
1
- (function(r,h){typeof exports=="object"&&typeof module<"u"?h(exports):typeof define=="function"&&define.amd?define(["exports"],h):(r=typeof globalThis<"u"?globalThis:r||self,h(r.Dopamine={}))})(this,function(r){"use strict";class h{constructor(){this.listeners=new Map}on(t,e){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(e),()=>this.off(t,e)}off(t,e){this.listeners.has(t)&&this.listeners.get(t).delete(e)}emit(t,e){this.listeners.has(t)&&this.listeners.get(t).forEach(i=>{try{i(e)}catch(s){console.error(`Error in listener for event '${t}':`,s)}})}clear(){this.listeners.clear()}}class p extends h{constructor(t,e={}){super(),this.dataService=t,this.player=null,this.achievements={...this._initAchievements(),...e.achievements||{}}}async init(){return this.player=await this.dataService.load("player",this._getDefaultPlayer()),this._updateDailyStreak(),await this.save(),this.player}_getDefaultPlayer(){return{name:"Player",xp:0,level:1,totalGamesPlayed:0,createdAt:Date.now(),lastPlayedAt:Date.now(),streak:{current:1,longest:1,lastPlayDate:this._getTodayDateString()},achievements:{},stats:{}}}async save(){this.player&&(this.player.lastPlayedAt=Date.now(),await this.dataService.save("player",this.player))}async addXP(t,e=""){const i=this.player.level;this.player.xp+=t;const s=this._calculateLevel(this.player.xp),a=s>i;return a&&(this.player.level=s),await this.save(),this.emit("xp_gained",{amount:t,reason:e,leveledUp:a,newLevel:s}),a&&this.emit("level_up",{oldLevel:i,newLevel:s}),{leveledUp:a,newLevel:s,xpGained:t}}_calculateLevel(t){const e=Math.floor((1+Math.sqrt(1+8*t/50))/2);return Math.max(1,e)}getXPForNextLevel(){const t=this.player.level+1,e=50*t*(t-1),i=50*this.player.level*(this.player.level-1);return{total:e,needed:e-this.player.xp,progress:(this.player.xp-i)/(e-i)}}async recordGame(t,e){this.player.stats[t]||(this.player.stats[t]={totalPlays:0,highScore:0});const i=this.player.stats[t];i.totalPlays++,this.player.totalGamesPlayed++,e.score!==void 0&&e.score>i.highScore&&(i.highScore=e.score,this.emit("new_high_score",{gameName:t,score:e.score})),Object.keys(e).forEach(a=>{a!=="score"&&(i[a]=(i[a]||0)+(typeof e[a]=="number"?e[a]:0))});let s=10;e.score&&(s+=Math.floor(e.score/5)),s=Math.floor(s*this.getStreakMultiplier()),await this.addXP(s,`Played ${t}`),await this.checkAchievements(t,e),await this.save()}async checkAchievements(t,e){const i=[];for(const[s,a]of Object.entries(this.achievements))this.player.achievements[s]||a.check(this.player,t,e)&&(await this.unlockAchievement(s),i.push(a));return i}async unlockAchievement(t){const e=this.achievements[t];return e?(this.player.achievements[t]={unlockedAt:Date.now(),seen:!1},await this.addXP(e.xp,`Achievement: ${e.name}`),this.emit("achievement_unlocked",e),await this.save(),!0):!1}getUnlockedAchievements(){return Object.entries(this.player.achievements).map(([t,e])=>({...this.achievements[t],id:t,...e}))}getUnseenAchievements(){return this.getUnlockedAchievements().filter(t=>!t.seen)}async markAchievementsSeen(t){for(const e of t)this.player.achievements[e]&&(this.player.achievements[e].seen=!0);await this.save()}_updateDailyStreak(){const t=this._getTodayDateString(),e=this.player.streak.lastPlayDate;if(e===t)return;const i=this._getYesterdayDateString();e===i?(this.player.streak.current++,this.player.streak.current>this.player.streak.longest&&(this.player.streak.longest=this.player.streak.current)):this.player.streak.current=1,this.player.streak.lastPlayDate=t}getStreakMultiplier(){const t=this.player.streak.current;return t>=7?2:t>=5?1.5:t>=3?1.25:1}_getTodayDateString(){return new Date().toISOString().split("T")[0]}_getYesterdayDateString(){const t=new Date;return t.setDate(t.getDate()-1),t.toISOString().split("T")[0]}_initAchievements(){return{first_game:{name:"First Steps",description:"Play your first game",icon:"🎮",xp:50,check:t=>t.totalGamesPlayed>=1},level_5:{name:"Rising Star",description:"Reach level 5",icon:"⭐",xp:100,check:t=>t.level>=5},level_10:{name:"Expert Player",description:"Reach level 10",icon:"🏆",xp:250,check:t=>t.level>=10},streak_3:{name:"Dedicated",description:"Play 3 days in a row",icon:"🔥",xp:100,check:t=>t.streak.current>=3},streak_7:{name:"Unstoppable",description:"Play 7 days in a row",icon:"💥",xp:300,check:t=>t.streak.current>=7}}}}class m{constructor(t={}){this.storage=t.storage||window.localStorage,this.prefix=t.prefix||"dopamine_"}async save(t,e){try{const i=JSON.stringify(e);return this.storage.setItem(this.prefix+t,i),!0}catch(i){return console.error("Error saving data:",i),!1}}async load(t,e=null){try{const i=this.storage.getItem(this.prefix+t);return i?JSON.parse(i):e}catch(i){return console.error("Error loading data:",i),e}}async clear(t){this.storage.removeItem(this.prefix+t)}}class y{constructor(t){this.particleSystem=t,this.container=null,this.xpBar=null,this.levelDisplay=null,this.streakDisplay=null,this.init()}init(){this.container=document.createElement("div"),this.container.id="game-ui-overlay",this.container.innerHTML=`
1
+ (function(r,h){typeof exports=="object"&&typeof module<"u"?h(exports):typeof define=="function"&&define.amd?define(["exports"],h):(r=typeof globalThis<"u"?globalThis:r||self,h(r.Dopamine={}))})(this,function(r){"use strict";class h{constructor(){this.listeners=new Map}on(t,e){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(e),()=>this.off(t,e)}off(t,e){this.listeners.has(t)&&this.listeners.get(t).delete(e)}emit(t,e){this.listeners.has(t)&&this.listeners.get(t).forEach(s=>{try{s(e)}catch(i){console.error(`Error in listener for event '${t}':`,i)}})}clear(){this.listeners.clear()}}class p extends h{constructor(t,e={}){super(),this.dataService=t,this.player=null,this.achievements={...this._initAchievements(),...e.achievements||{}}}async init(){return this.player=await this.dataService.load("player",this._getDefaultPlayer()),this._updateDailyStreak(),await this.save(),this.player}_getDefaultPlayer(){return{name:"Player",xp:0,level:1,totalGamesPlayed:0,createdAt:Date.now(),lastPlayedAt:Date.now(),streak:{current:1,longest:1,lastPlayDate:this._getTodayDateString()},achievements:{},stats:{}}}async save(){this.player&&(this.player.lastPlayedAt=Date.now(),await this.dataService.save("player",this.player))}async addXP(t,e=""){const s=this.player.level;this.player.xp+=t;const i=this._calculateLevel(this.player.xp),a=i>s;return a&&(this.player.level=i),await this.save(),this.emit("xp_gained",{amount:t,reason:e,leveledUp:a,newLevel:i}),a&&this.emit("level_up",{oldLevel:s,newLevel:i}),{leveledUp:a,newLevel:i,xpGained:t}}_calculateLevel(t){const e=Math.floor((1+Math.sqrt(1+8*t/50))/2);return Math.max(1,e)}getXPForNextLevel(){const t=this.player.level+1,e=50*t*(t-1),s=50*this.player.level*(this.player.level-1);return{total:e,needed:e-this.player.xp,progress:(this.player.xp-s)/(e-s)}}async recordGame(t,e){this.player.stats[t]||(this.player.stats[t]={totalPlays:0,highScore:0});const s=this.player.stats[t];s.totalPlays++,this.player.totalGamesPlayed++,e.score!==void 0&&e.score>s.highScore&&(s.highScore=e.score,this.emit("new_high_score",{gameName:t,score:e.score})),Object.keys(e).forEach(a=>{a!=="score"&&(s[a]=(s[a]||0)+(typeof e[a]=="number"?e[a]:0))});let i=10;e.score&&(i+=Math.floor(e.score/5)),i=Math.floor(i*this.getStreakMultiplier()),await this.addXP(i,`Played ${t}`),await this.checkAchievements(t,e),await this.save()}async checkAchievements(t,e){const s=[];for(const[i,a]of Object.entries(this.achievements))this.player.achievements[i]||a.check(this.player,t,e)&&(await this.unlockAchievement(i),s.push(a));return s}async unlockAchievement(t){const e=this.achievements[t];return e?(this.player.achievements[t]={unlockedAt:Date.now(),seen:!1},await this.addXP(e.xp,`Achievement: ${e.name}`),this.emit("achievement_unlocked",e),await this.save(),!0):!1}getUnlockedAchievements(){return Object.entries(this.player.achievements).map(([t,e])=>({...this.achievements[t],id:t,...e}))}getUnseenAchievements(){return this.getUnlockedAchievements().filter(t=>!t.seen)}async markAchievementsSeen(t){for(const e of t)this.player.achievements[e]&&(this.player.achievements[e].seen=!0);await this.save()}_updateDailyStreak(){const t=this._getTodayDateString(),e=this.player.streak.lastPlayDate;if(e===t)return;const s=this._getYesterdayDateString();e===s?(this.player.streak.current++,this.player.streak.current>this.player.streak.longest&&(this.player.streak.longest=this.player.streak.current)):this.player.streak.current=1,this.player.streak.lastPlayDate=t}getStreakMultiplier(){const t=this.player.streak.current;return t>=7?2:t>=5?1.5:t>=3?1.25:1}_getTodayDateString(){return new Date().toISOString().split("T")[0]}_getYesterdayDateString(){const t=new Date;return t.setDate(t.getDate()-1),t.toISOString().split("T")[0]}_initAchievements(){return{first_game:{name:"First Steps",description:"Play your first game",icon:"🎮",xp:50,check:t=>t.totalGamesPlayed>=1},level_5:{name:"Rising Star",description:"Reach level 5",icon:"⭐",xp:100,check:t=>t.level>=5},level_10:{name:"Expert Player",description:"Reach level 10",icon:"🏆",xp:250,check:t=>t.level>=10},streak_3:{name:"Dedicated",description:"Play 3 days in a row",icon:"🔥",xp:100,check:t=>t.streak.current>=3},streak_7:{name:"Unstoppable",description:"Play 7 days in a row",icon:"💥",xp:300,check:t=>t.streak.current>=7}}}}class u{constructor(t={}){this.storage=t.storage||window.localStorage,this.prefix=t.prefix||"dopamine_"}async save(t,e){try{const s=JSON.stringify(e);return this.storage.setItem(this.prefix+t,s),!0}catch(s){return console.error("Error saving data:",s),!1}}async load(t,e=null){try{const s=this.storage.getItem(this.prefix+t);return s?JSON.parse(s):e}catch(s){return console.error("Error loading data:",s),e}}async clear(t){this.storage.removeItem(this.prefix+t)}}class m{constructor(t){this.particleSystem=t,this.container=null,this.xpBar=null,this.levelDisplay=null,this.streakDisplay=null,this.init()}init(){this.container=document.createElement("div"),this.container.id="game-ui-overlay",this.container.innerHTML=`
2
2
  <div class="game-ui-top-bar">
3
3
  <div class="level-badge" id="level-badge">
4
4
  <span class="level-label">LVL</span>
@@ -17,7 +17,7 @@
17
17
  <span class="streak-number" id="streak-number">1</span>
18
18
  </div>
19
19
  </div>
20
- `,document.body.appendChild(this.container),this.xpBar=document.getElementById("xp-bar-fill"),this.xpText=document.getElementById("xp-text"),this.levelDisplay=document.getElementById("level-number"),this.streakDisplay=document.getElementById("streak-number")}updateXP(t,e,i){const s=t/i*100;this.xpBar.style.width=`${Math.min(100,s)}%`,this.xpText.textContent=`${t} / ${i} XP`,this.xpBar.style.animation="none",setTimeout(()=>{this.xpBar.style.animation="xp-pulse 0.3s ease-out"},10)}updateLevel(t){this.levelDisplay.textContent=t;const e=document.getElementById("level-badge");e.style.animation="none",setTimeout(()=>{e.style.animation="level-pulse 0.5s ease-out"},10)}updateStreak(t){this.streakDisplay.textContent=t;const e=document.getElementById("streak-badge");t>=7?e.style.background="linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)":t>=3&&(e.style.background="linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)")}showAchievement(t){const e=document.createElement("div");e.className="achievement-popup",e.innerHTML=`
20
+ `,document.body.appendChild(this.container),this.xpBar=document.getElementById("xp-bar-fill"),this.xpText=document.getElementById("xp-text"),this.levelDisplay=document.getElementById("level-number"),this.streakDisplay=document.getElementById("streak-number")}updateXP(t,e,s){const i=t/s*100;this.xpBar.style.width=`${Math.min(100,i)}%`,this.xpText.textContent=`${t} / ${s} XP`,this.xpBar.style.animation="none",setTimeout(()=>{this.xpBar.style.animation="xp-pulse 0.3s ease-out"},10)}updateLevel(t){this.levelDisplay.textContent=t;const e=document.getElementById("level-badge");e.style.animation="none",setTimeout(()=>{e.style.animation="level-pulse 0.5s ease-out"},10)}updateStreak(t){this.streakDisplay.textContent=t;const e=document.getElementById("streak-badge");t>=7?e.style.background="linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)":t>=3&&(e.style.background="linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)")}showAchievement(t){const e=document.createElement("div");e.className="achievement-popup",e.innerHTML=`
21
21
  <div class="achievement-shine"></div>
22
22
  <div class="achievement-content">
23
23
  <div class="achievement-icon">${t.icon}</div>
@@ -28,18 +28,18 @@
28
28
  <div class="achievement-xp">+${t.xp} XP</div>
29
29
  </div>
30
30
  </div>
31
- `,document.body.appendChild(e),setTimeout(()=>{const i=e.getBoundingClientRect();this.particleSystem.confetti(i.left+i.width/2,i.top+i.height/2,40)},300),setTimeout(()=>{e.style.animation="slideOut 0.3s ease-in forwards",setTimeout(()=>e.remove(),300)},4e3)}showLevelUp(t,e){const i=document.createElement("div");i.className="level-up-overlay",i.innerHTML=`
31
+ `,document.body.appendChild(e),setTimeout(()=>{const s=e.getBoundingClientRect();this.particleSystem.confetti(s.left+s.width/2,s.top+s.height/2,40)},300),setTimeout(()=>{e.style.animation="slideOut 0.3s ease-in forwards",setTimeout(()=>e.remove(),300)},4e3)}showLevelUp(t,e){const s=document.createElement("div");s.className="level-up-overlay",s.innerHTML=`
32
32
  <div class="level-up-content">
33
33
  <div class="level-up-title">LEVEL UP!</div>
34
34
  <div class="level-up-number">${e}</div>
35
35
  <div class="level-up-subtitle">Amazing progress!</div>
36
36
  </div>
37
- `,document.body.appendChild(i),setTimeout(()=>{for(let s=0;s<5;s++)setTimeout(()=>{const a=Math.random()*window.innerWidth,n=Math.random()*window.innerHeight*.6;this.particleSystem.confetti(a,n,30)},s*200)},300),setTimeout(()=>{i.style.opacity="0",setTimeout(()=>i.remove(),500)},2500)}showFloatingText(t,e,i,s="#ffd700",a="24px"){const n=document.createElement("div");n.className="floating-text",n.textContent=t,n.style.left=e+"px",n.style.top=i+"px",n.style.color=s,n.style.fontSize=a,document.body.appendChild(n),setTimeout(()=>n.remove(),2e3)}showCombo(t,e,i){this.showFloatingText(`${t}× COMBO!`,e,i,"#ff6b6b","32px"),this.particleSystem.fire(e,i,20)}showNearMiss(t,e){this.showFloatingText("CLOSE CALL!",t,e,"#45b7d1","20px"),this.particleSystem.sparkle(t,e,15,"#45b7d1")}showLuckyMoment(t,e,i){this.showFloatingText(`LUCKY ${t}×!`,e,i,"#ffd700","36px"),this.particleSystem.starBurst(e,i,12),this.particleSystem.confetti(e,i,25)}showNotification(t,e="normal"){const i=window.innerWidth/2,s=window.innerHeight/3;e==="legendary"?this.showLuckyMoment(t,i,s):e==="rare"?this.showCombo(t,i,s):this.showFloatingText(t,i,s,"#fff","24px")}showSummary(t){const e=document.createElement("div");e.className="message-box",e.style.display="flex",e.style.position="absolute",e.style.top="50%",e.style.left="50%",e.style.transform="translate(-50%, -50%)",e.style.zIndex="1000";let i="";t.metrics&&(i=Object.entries(t.metrics).map(([s,a])=>`<div style="display:flex;justify-content:space-between;width:100%"><span>${s}:</span> <strong>${a}</strong></div>`).join("")),e.innerHTML=`
37
+ `,document.body.appendChild(s),setTimeout(()=>{for(let i=0;i<5;i++)setTimeout(()=>{const a=Math.random()*window.innerWidth,n=Math.random()*window.innerHeight*.6;this.particleSystem.confetti(a,n,30)},i*200)},300),setTimeout(()=>{s.style.opacity="0",setTimeout(()=>s.remove(),500)},2500)}showFloatingText(t,e,s,i="#ffd700",a="24px"){const n=document.createElement("div");n.className="floating-text",n.textContent=t,n.style.left=e+"px",n.style.top=s+"px",n.style.color=i,n.style.fontSize=a,document.body.appendChild(n),setTimeout(()=>n.remove(),2e3)}showCombo(t,e,s){this.showFloatingText(`${t}× COMBO!`,e,s,"#ff6b6b","32px"),this.particleSystem.fire(e,s,20)}showNearMiss(t,e){this.showFloatingText("CLOSE CALL!",t,e,"#45b7d1","20px"),this.particleSystem.sparkle(t,e,15,"#45b7d1")}showLuckyMoment(t,e,s){this.showFloatingText(`LUCKY ${t}×!`,e,s,"#ffd700","36px"),this.particleSystem.starBurst(e,s,12),this.particleSystem.confetti(e,s,25)}showNotification(t,e="normal"){const s=window.innerWidth/2,i=window.innerHeight/3;e==="legendary"?this.showLuckyMoment(t,s,i):e==="rare"?this.showCombo(t,s,i):this.showFloatingText(t,s,i,"#fff","24px")}showSummary(t){const e=document.createElement("div");e.className="message-box",e.style.display="flex",e.style.position="absolute",e.style.top="50%",e.style.left="50%",e.style.transform="translate(-50%, -50%)",e.style.zIndex="1000";let s="";t.metrics&&(s=Object.entries(t.metrics).map(([i,a])=>`<div style="display:flex;justify-content:space-between;width:100%"><span>${i}:</span> <strong>${a}</strong></div>`).join("")),e.innerHTML=`
38
38
  <h1>Game Over</h1>
39
39
  <div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${t.score}</div>
40
40
  <div style="width: 100%; margin-bottom: 20px;">
41
- ${i}
41
+ ${s}
42
42
  </div>
43
43
  <button class="btn" onclick="location.reload()">Play Again</button>
44
44
  <button class="btn" onclick="window.location.href='index.html'" style="margin-top: 10px; background: #543847;">Exit</button>
45
- `,document.body.appendChild(e)}}class v{constructor(t={}){this.canvasId=t.canvasId||"dopamine-particle-canvas",this.canvas=document.getElementById(this.canvasId),this.canvas||(this.canvas=document.createElement("canvas"),this.canvas.id=this.canvasId,this.canvas.style.position="fixed",this.canvas.style.top="0",this.canvas.style.left="0",this.canvas.style.width="100%",this.canvas.style.height="100%",this.canvas.style.pointerEvents="none",this.canvas.style.zIndex=t.zIndex||"9999",document.body.appendChild(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.particles=[],this.isAnimating=!1,this._resize(),window.addEventListener("resize",()=>this._resize())}_resize(){this.canvas.width=window.innerWidth,this.canvas.height=window.innerHeight}confetti(t,e,i=50){const s=["#ff6b6b","#4ecdc4","#45b7d1","#f7dc6f","#c06c84","#6c5ce7"];for(let a=0;a<i;a++)this.particles.push({x:t,y:e,vx:(Math.random()-.5)*10,vy:(Math.random()-.5)*10-5,rotation:Math.random()*Math.PI*2,rotationSpeed:(Math.random()-.5)*.2,color:s[Math.floor(Math.random()*s.length)],size:Math.random()*8+4,life:1,decay:.015+Math.random()*.01,gravity:.3,type:"confetti"});this._startAnimation()}sparkle(t,e,i=20,s="#ffd700"){for(let a=0;a<i;a++){const n=Math.PI*2/i*a,o=2+Math.random()*3;this.particles.push({x:t,y:e,vx:Math.cos(n)*o,vy:Math.sin(n)*o,color:s,size:Math.random()*4+2,life:1,decay:.02,gravity:-.1,type:"spark"})}this._startAnimation()}coinShower(t,e,i=10){for(let s=0;s<i;s++)this.particles.push({x:t+(Math.random()-.5)*100,y:e-Math.random()*50,vx:(Math.random()-.5)*3,vy:Math.random()*-2-3,color:"#ffd700",size:12,life:1,decay:.012,gravity:.4,type:"coin"});this._startAnimation()}fire(t,e,i=15){const s=["#ff6b00","#ff8800","#ffaa00","#ff4400"];for(let a=0;a<i;a++)this.particles.push({x:t+(Math.random()-.5)*20,y:e,vx:(Math.random()-.5)*2,vy:-Math.random()*3-2,color:s[Math.floor(Math.random()*s.length)],size:Math.random()*6+3,life:1,decay:.025,gravity:-.2,type:"fire"});this._startAnimation()}starBurst(t,e,i=8){for(let s=0;s<i;s++){const a=Math.PI*2/i*s,n=4;this.particles.push({x:t,y:e,vx:Math.cos(a)*n,vy:Math.sin(a)*n,rotation:a,color:"#ffffff",size:15,life:1,decay:.03,gravity:0,type:"star"})}this._startAnimation()}_startAnimation(){this.isAnimating||(this.isAnimating=!0,this._animate())}_animate(){this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);for(let t=this.particles.length-1;t>=0;t--){const e=this.particles[t];if(e.vy+=e.gravity,e.x+=e.vx,e.y+=e.vy,e.life-=e.decay,e.rotationSpeed&&(e.rotation+=e.rotationSpeed),e.life<=0){this.particles.splice(t,1);continue}this.ctx.save(),this.ctx.globalAlpha=e.life,this.ctx.fillStyle=e.color,e.type==="confetti"?(this.ctx.translate(e.x,e.y),this.ctx.rotate(e.rotation),this.ctx.fillRect(-e.size/2,-e.size/2,e.size,e.size/2)):e.type==="coin"?(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill(),this.ctx.fillStyle="#ffee88",this.ctx.beginPath(),this.ctx.arc(e.x-e.size/3,e.y-e.size/3,e.size/3,0,Math.PI*2),this.ctx.fill()):e.type==="star"?this._drawStar(e.x,e.y,5,e.size,e.size/2,e.rotation):(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill()),this.ctx.restore()}this.particles.length>0?requestAnimationFrame(()=>this._animate()):this.isAnimating=!1}_drawStar(t,e,i,s,a,n){let o=Math.PI/2*3+n;const f=Math.PI/i;this.ctx.beginPath(),this.ctx.moveTo(t,e-s);for(let g=0;g<i;g++){let c=t+Math.cos(o)*s,d=e+Math.sin(o)*s;this.ctx.lineTo(c,d),o+=f,c=t+Math.cos(o)*a,d=e+Math.sin(o)*a,this.ctx.lineTo(c,d),o+=f}this.ctx.lineTo(t,e-s),this.ctx.closePath(),this.ctx.fill()}clear(){this.particles=[],this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}}class u{constructor(t={}){this.audioContext=null,this.storageKey=t.storageKey||"dopamineSoundsMuted",this.muted=localStorage.getItem(this.storageKey)==="true"}initAudio(){this.audioContext||(this.audioContext=new(window.AudioContext||window.webkitAudioContext))}toggleMute(){return this.muted=!this.muted,localStorage.setItem(this.storageKey,this.muted),this.muted}playTone(t,e,i="sine",s=.3){if(this.muted)return;this.initAudio();const a=this.audioContext.createOscillator(),n=this.audioContext.createGain();a.connect(n),n.connect(this.audioContext.destination),a.frequency.value=t,a.type=i,n.gain.setValueAtTime(s,this.audioContext.currentTime),n.gain.exponentialRampToValueAtTime(.01,this.audioContext.currentTime+e),a.start(this.audioContext.currentTime),a.stop(this.audioContext.currentTime+e)}playJump(){this.playTone(400,.1,"square",.2)}playScore(){this.playTone(800,.15,"sine",.25),setTimeout(()=>this.playTone(1e3,.15,"sine",.25),50)}playGameOver(){this.playTone(300,.2,"sawtooth",.3),setTimeout(()=>this.playTone(200,.2,"sawtooth",.3),100),setTimeout(()=>this.playTone(150,.3,"sawtooth",.3),200)}playClick(){this.playTone(600,.05,"sine",.1)}playSuccess(){this.playTone(600,.1,"sine",.2),setTimeout(()=>this.playTone(800,.2,"sine",.2),100)}playError(){this.playTone(200,.2,"sawtooth",.3)}}class x{constructor(t={}){this.config=t,this.dataService=new m(t.data||{}),this.rewardSystem=new p(this.dataService,t.rewards||{}),this.soundManager=new u(t.sound||{}),this.particleSystem=new v(t.particles||{}),this.gameUI=new y(this.particleSystem),this._bindEvents()}async init(){return await this.rewardSystem.init(),console.log("DopamineJS Initialized 🚀"),{rewardSystem:this.rewardSystem,gameUI:this.gameUI,particleSystem:this.particleSystem,soundManager:this.soundManager}}_bindEvents(){this.rewardSystem.on("xp_gained",t=>{const{total:e,needed:i}=this.rewardSystem.getXPForNextLevel();this.gameUI.updateXP(this.rewardSystem.player.xp,i,e),t.xpGained>0}),this.rewardSystem.on("level_up",t=>{this.gameUI.updateLevel(t.newLevel),this.gameUI.showLevelUp(t.oldLevel,t.newLevel)}),this.rewardSystem.on("achievement_unlocked",t=>{this.gameUI.showAchievement(t)}),this.rewardSystem.on("new_high_score",t=>{this.gameUI.showNotification(`New High Score: ${t.score}!`,"legendary"),this.soundManager.playSuccess()})}}r.DataService=m,r.GameUI=y,r.ParticleSystem=v,r.RewardSystem=p,r.SoundManager=u,r.default=x,Object.defineProperties(r,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
45
+ `,document.body.appendChild(e)}}class y{constructor(t={}){this.canvasId=t.canvasId||"dopamine-particle-canvas",this.canvas=document.getElementById(this.canvasId),this.canvas||(this.canvas=document.createElement("canvas"),this.canvas.id=this.canvasId,this.canvas.style.position="fixed",this.canvas.style.top="0",this.canvas.style.left="0",this.canvas.style.width="100%",this.canvas.style.height="100%",this.canvas.style.pointerEvents="none",this.canvas.style.zIndex=t.zIndex||"9999",document.body.appendChild(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.particles=[],this.isAnimating=!1,this._resize(),window.addEventListener("resize",()=>this._resize())}_resize(){this.canvas.width=window.innerWidth,this.canvas.height=window.innerHeight}confetti(t,e,s=50){const i=["#ff6b6b","#4ecdc4","#45b7d1","#f7dc6f","#c06c84","#6c5ce7"];for(let a=0;a<s;a++)this.particles.push({x:t,y:e,vx:(Math.random()-.5)*10,vy:(Math.random()-.5)*10-5,rotation:Math.random()*Math.PI*2,rotationSpeed:(Math.random()-.5)*.2,color:i[Math.floor(Math.random()*i.length)],size:Math.random()*8+4,life:1,decay:.015+Math.random()*.01,gravity:.3,type:"confetti"});this._startAnimation()}sparkle(t,e,s=20,i="#ffd700"){for(let a=0;a<s;a++){const n=Math.PI*2/s*a,o=2+Math.random()*3;this.particles.push({x:t,y:e,vx:Math.cos(n)*o,vy:Math.sin(n)*o,color:i,size:Math.random()*4+2,life:1,decay:.02,gravity:-.1,type:"spark"})}this._startAnimation()}coinShower(t,e,s=10){for(let i=0;i<s;i++)this.particles.push({x:t+(Math.random()-.5)*100,y:e-Math.random()*50,vx:(Math.random()-.5)*3,vy:Math.random()*-2-3,color:"#ffd700",size:12,life:1,decay:.012,gravity:.4,type:"coin"});this._startAnimation()}fire(t,e,s=15){const i=["#ff6b00","#ff8800","#ffaa00","#ff4400"];for(let a=0;a<s;a++)this.particles.push({x:t+(Math.random()-.5)*20,y:e,vx:(Math.random()-.5)*2,vy:-Math.random()*3-2,color:i[Math.floor(Math.random()*i.length)],size:Math.random()*6+3,life:1,decay:.025,gravity:-.2,type:"fire"});this._startAnimation()}starBurst(t,e,s=8){for(let i=0;i<s;i++){const a=Math.PI*2/s*i,n=4;this.particles.push({x:t,y:e,vx:Math.cos(a)*n,vy:Math.sin(a)*n,rotation:a,color:"#ffffff",size:15,life:1,decay:.03,gravity:0,type:"star"})}this._startAnimation()}_startAnimation(){this.isAnimating||(this.isAnimating=!0,this._animate())}_animate(){this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);for(let t=this.particles.length-1;t>=0;t--){const e=this.particles[t];if(e.vy+=e.gravity,e.x+=e.vx,e.y+=e.vy,e.life-=e.decay,e.rotationSpeed&&(e.rotation+=e.rotationSpeed),e.life<=0){this.particles.splice(t,1);continue}this.ctx.save(),this.ctx.globalAlpha=e.life,this.ctx.fillStyle=e.color,e.type==="confetti"?(this.ctx.translate(e.x,e.y),this.ctx.rotate(e.rotation),this.ctx.fillRect(-e.size/2,-e.size/2,e.size,e.size/2)):e.type==="coin"?(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill(),this.ctx.fillStyle="#ffee88",this.ctx.beginPath(),this.ctx.arc(e.x-e.size/3,e.y-e.size/3,e.size/3,0,Math.PI*2),this.ctx.fill()):e.type==="star"?this._drawStar(e.x,e.y,5,e.size,e.size/2,e.rotation):(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill()),this.ctx.restore()}this.particles.length>0?requestAnimationFrame(()=>this._animate()):this.isAnimating=!1}_drawStar(t,e,s,i,a,n){let o=Math.PI/2*3+n;const f=Math.PI/s;this.ctx.beginPath(),this.ctx.moveTo(t,e-i);for(let g=0;g<s;g++){let c=t+Math.cos(o)*i,d=e+Math.sin(o)*i;this.ctx.lineTo(c,d),o+=f,c=t+Math.cos(o)*a,d=e+Math.sin(o)*a,this.ctx.lineTo(c,d),o+=f}this.ctx.lineTo(t,e-i),this.ctx.closePath(),this.ctx.fill()}clear(){this.particles=[],this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}}class v{constructor(t={}){this.audioContext=null,this.storageKey=t.storageKey||"dopamineSoundsMuted",this.muted=localStorage.getItem(this.storageKey)==="true",this.assets=new Map,this.soundPack=t.soundPack||"synth",this.customSounds=t.customSounds||{},Object.keys(this.customSounds).length>0&&this.preloadSounds(this.customSounds)}initAudio(){if(!this.audioContext){const t=window.AudioContext||window.webkitAudioContext;this.audioContext=new t}this.audioContext.state==="suspended"&&this.audioContext.resume()}toggleMute(){return this.muted=!this.muted,localStorage.setItem(this.storageKey,this.muted),this.muted}registerSound(t,e){this.customSounds[t]=e,this.audioContext&&this.loadSound(t,e)}async preloadSounds(t){const e=Object.entries(t).map(([s,i])=>this.loadSound(s,i));await Promise.allSettled(e)}async loadSound(t,e){try{if(!this.audioContext)return;const i=await(await fetch(e)).arrayBuffer(),a=await this.audioContext.decodeAudioData(i);this.assets.set(t,a)}catch(s){console.warn(`[DopamineJS] Failed to load sound '${t}':`,s)}}async play(t){if(this.muted)return;if(this.initAudio(),this.assets.has(t)){this._playBuffer(this.assets.get(t));return}if(this.customSounds[t]&&!this.assets.has(t)&&(await this.loadSound(t,this.customSounds[t]),this.assets.has(t))){this._playBuffer(this.assets.get(t));return}const e={jump:()=>this.playJump(!0),score:()=>this.playScore(!0),success:()=>this.playSuccess(!0),click:()=>this.playClick(!0),error:()=>this.playError(!0),gameover:()=>this.playGameOver(!0)};e[t]&&e[t]()}_playBuffer(t){const e=this.audioContext.createBufferSource();e.buffer=t,e.connect(this.audioContext.destination),e.start(0)}playTone(t,e,s="sine",i=.3){if(this.muted)return;this.initAudio();const a=this.audioContext.createOscillator(),n=this.audioContext.createGain();a.connect(n),n.connect(this.audioContext.destination),a.frequency.value=t,a.type=s,n.gain.setValueAtTime(i,this.audioContext.currentTime),n.gain.exponentialRampToValueAtTime(.01,this.audioContext.currentTime+e),a.start(this.audioContext.currentTime),a.stop(this.audioContext.currentTime+e)}playJump(t=!1){if(!t&&this.assets.has("jump"))return this.play("jump");this.playTone(400,.1,"square",.2)}playScore(t=!1){if(!t&&this.assets.has("score"))return this.play("score");this.playTone(800,.15,"sine",.25),setTimeout(()=>this.playTone(1e3,.15,"sine",.25),50)}playGameOver(t=!1){if(!t&&this.assets.has("gameover"))return this.play("gameover");this.playTone(300,.2,"sawtooth",.3),setTimeout(()=>this.playTone(200,.2,"sawtooth",.3),100),setTimeout(()=>this.playTone(150,.3,"sawtooth",.3),200)}playClick(t=!1){if(!t&&this.assets.has("click"))return this.play("click");this.playTone(600,.05,"sine",.1)}playSuccess(t=!1){if(!t&&this.assets.has("success"))return this.play("success");this.playTone(600,.1,"sine",.2),setTimeout(()=>this.playTone(800,.2,"sine",.2),100)}playError(t=!1){if(!t&&this.assets.has("error"))return this.play("error");this.playTone(200,.2,"sawtooth",.3)}}class x{constructor(t={}){this.config=t,this.dataService=new u(t.data||{}),this.rewardSystem=new p(this.dataService,t.rewards||{}),this.soundManager=new v(t.sound||{}),this.particleSystem=new y(t.particles||{}),this.gameUI=new m(this.particleSystem),this._bindEvents()}async init(){return await this.rewardSystem.init(),console.log("DopamineJS Initialized 🚀"),{rewardSystem:this.rewardSystem,gameUI:this.gameUI,particleSystem:this.particleSystem,soundManager:this.soundManager}}_bindEvents(){this.rewardSystem.on("xp_gained",t=>{const{total:e,needed:s}=this.rewardSystem.getXPForNextLevel();this.gameUI.updateXP(this.rewardSystem.player.xp,s,e),t.xpGained>0}),this.rewardSystem.on("level_up",t=>{this.gameUI.updateLevel(t.newLevel),this.gameUI.showLevelUp(t.oldLevel,t.newLevel)}),this.rewardSystem.on("achievement_unlocked",t=>{this.gameUI.showAchievement(t)}),this.rewardSystem.on("new_high_score",t=>{this.gameUI.showNotification(`New High Score: ${t.score}!`,"legendary"),this.soundManager.playSuccess()})}}r.DataService=u,r.GameUI=m,r.ParticleSystem=y,r.RewardSystem=p,r.SoundManager=v,r.default=x,Object.defineProperties(r,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dopaminejs",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A lightweight library for adding juice, rewards, and feedback to HTML5 games.",
5
5
  "main": "dist/dopamine.umd.js",
6
6
  "module": "dist/dopamine.mjs",
@@ -9,7 +9,9 @@
9
9
  "dist",
10
10
  "src",
11
11
  "README.md",
12
- "LICENSE"
12
+ "LICENSE",
13
+ "CHANGELOG.md",
14
+ "ARCHITECTURE.md"
13
15
  ],
14
16
  "exports": {
15
17
  ".": {
@@ -8,6 +8,16 @@ export class SoundManager {
8
8
  this.audioContext = null;
9
9
  this.storageKey = config.storageKey || 'dopamineSoundsMuted';
10
10
  this.muted = localStorage.getItem(this.storageKey) === 'true';
11
+
12
+ // Asset management
13
+ this.assets = new Map(); // key -> AudioBuffer
14
+ this.soundPack = config.soundPack || 'synth'; // 'synth' or custom
15
+ this.customSounds = config.customSounds || {}; // key -> url
16
+
17
+ // Preload custom sounds if provided
18
+ if (Object.keys(this.customSounds).length > 0) {
19
+ this.preloadSounds(this.customSounds);
20
+ }
11
21
  }
12
22
 
13
23
  /**
@@ -15,7 +25,11 @@ export class SoundManager {
15
25
  */
16
26
  initAudio() {
17
27
  if (!this.audioContext) {
18
- this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
28
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
29
+ this.audioContext = new AudioContext();
30
+ }
31
+ if (this.audioContext.state === 'suspended') {
32
+ this.audioContext.resume();
19
33
  }
20
34
  }
21
35
 
@@ -29,12 +43,98 @@ export class SoundManager {
29
43
  return this.muted;
30
44
  }
31
45
 
46
+ /**
47
+ * Register a custom sound URL
48
+ * @param {string} key - Unique identifier (e.g., 'jump', 'win')
49
+ * @param {string} url - Path to audio file
50
+ */
51
+ registerSound(key, url) {
52
+ this.customSounds[key] = url;
53
+ // Auto-load if context exists, otherwise wait for init
54
+ if (this.audioContext) {
55
+ this.loadSound(key, url);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Preload multiple sounds
61
+ * @param {Object} soundMap - { key: url }
62
+ */
63
+ async preloadSounds(soundMap) {
64
+ const promises = Object.entries(soundMap).map(([key, url]) => this.loadSound(key, url));
65
+ await Promise.allSettled(promises);
66
+ }
67
+
68
+ /**
69
+ * Load and decode a sound file
70
+ */
71
+ async loadSound(key, url) {
72
+ try {
73
+ // We need audio context to decode
74
+ if (!this.audioContext) {
75
+ // If no context yet, we can't decode.
76
+ // In a real app, we might fetch buffer first, decode later.
77
+ // For simplicity, we'll wait for initAudio() or lazy load.
78
+ return;
79
+ }
80
+
81
+ const response = await fetch(url);
82
+ const arrayBuffer = await response.arrayBuffer();
83
+ const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
84
+ this.assets.set(key, audioBuffer);
85
+ } catch (error) {
86
+ console.warn(`[DopamineJS] Failed to load sound '${key}':`, error);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Play a sound by key (custom) or fallback to synth
92
+ * @param {string} key - 'jump', 'score', 'success', etc.
93
+ */
94
+ async play(key) {
95
+ if (this.muted) return;
96
+ this.initAudio();
97
+
98
+ // 1. Try custom asset first
99
+ if (this.assets.has(key)) {
100
+ this._playBuffer(this.assets.get(key));
101
+ return;
102
+ }
103
+
104
+ // 2. Try to load if registered but not loaded
105
+ if (this.customSounds[key] && !this.assets.has(key)) {
106
+ await this.loadSound(key, this.customSounds[key]);
107
+ if (this.assets.has(key)) {
108
+ this._playBuffer(this.assets.get(key));
109
+ return;
110
+ }
111
+ }
112
+
113
+ // 3. Fallback to synth based on key name
114
+ // Map common keys to synth methods
115
+ const synthMap = {
116
+ 'jump': () => this.playJump(true), // true = force synth
117
+ 'score': () => this.playScore(true),
118
+ 'success': () => this.playSuccess(true),
119
+ 'click': () => this.playClick(true),
120
+ 'error': () => this.playError(true),
121
+ 'gameover': () => this.playGameOver(true)
122
+ };
123
+
124
+ if (synthMap[key]) {
125
+ synthMap[key]();
126
+ }
127
+ }
128
+
129
+ _playBuffer(buffer) {
130
+ const source = this.audioContext.createBufferSource();
131
+ source.buffer = buffer;
132
+ source.connect(this.audioContext.destination);
133
+ source.start(0);
134
+ }
135
+
32
136
  /**
33
137
  * Play a synthesized tone
34
- * @param {number} frequency
35
- * @param {number} duration
36
- * @param {string} type - 'sine', 'square', 'sawtooth', 'triangle'
37
- * @param {number} volume
38
138
  */
39
139
  playTone(frequency, duration, type = 'sine', volume = 0.3) {
40
140
  if (this.muted) return;
@@ -56,33 +156,39 @@ export class SoundManager {
56
156
  oscillator.stop(this.audioContext.currentTime + duration);
57
157
  }
58
158
 
59
- // --- Presets ---
159
+ // --- Presets (Synth) ---
60
160
 
61
- playJump() {
161
+ playJump(force = false) {
162
+ if (!force && this.assets.has('jump')) return this.play('jump');
62
163
  this.playTone(400, 0.1, 'square', 0.2);
63
164
  }
64
165
 
65
- playScore() {
166
+ playScore(force = false) {
167
+ if (!force && this.assets.has('score')) return this.play('score');
66
168
  this.playTone(800, 0.15, 'sine', 0.25);
67
169
  setTimeout(() => this.playTone(1000, 0.15, 'sine', 0.25), 50);
68
170
  }
69
171
 
70
- playGameOver() {
172
+ playGameOver(force = false) {
173
+ if (!force && this.assets.has('gameover')) return this.play('gameover');
71
174
  this.playTone(300, 0.2, 'sawtooth', 0.3);
72
175
  setTimeout(() => this.playTone(200, 0.2, 'sawtooth', 0.3), 100);
73
176
  setTimeout(() => this.playTone(150, 0.3, 'sawtooth', 0.3), 200);
74
177
  }
75
178
 
76
- playClick() {
179
+ playClick(force = false) {
180
+ if (!force && this.assets.has('click')) return this.play('click');
77
181
  this.playTone(600, 0.05, 'sine', 0.1);
78
182
  }
79
183
 
80
- playSuccess() {
184
+ playSuccess(force = false) {
185
+ if (!force && this.assets.has('success')) return this.play('success');
81
186
  this.playTone(600, 0.1, 'sine', 0.2);
82
187
  setTimeout(() => this.playTone(800, 0.2, 'sine', 0.2), 100);
83
188
  }
84
189
 
85
- playError() {
190
+ playError(force = false) {
191
+ if (!force && this.assets.has('error')) return this.play('error');
86
192
  this.playTone(200, 0.2, 'sawtooth', 0.3);
87
193
  }
88
194
  }